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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
'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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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>
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>
Both attempts removed from the branch by user direction after live
gates (attempt 1 culled the interior — the 'fixed' exit was a
nothing-drawn false positive; attempt 2's luminosity port produced no
visible change, theory-vs-plumbing unresolved). Patches preserved in
the session scratchpad. Next attempt starts with apparatus: tunnel
freeze probe, RenderDoc capture, ACViewer oracle, retail side-by-side.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
While a destination reservation hides the world behind the tunnel, the
frame meter runs on a hold-widened profile (2 ms -> 8 ms destination
lane, non-destination lane absolute caps unchanged), reverting the frame
the reservation ends. Measured: warm-process portal hold gate-ready
3.6 s / total 8.7 s (retail-feel band); login drip 6-7 s -> 3.0 s with
the remaining login floor now attributed to process cold-start warmup
(~8 s concurrent phase), the next #418 lever. Tunnel held 64-66 fps
through the burst; mid-game no-hold path bit-identical (test-pinned).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
While a destination reservation hides the world behind the authored
tunnel, the streaming frame meter now runs a hold-widened profile
(StreamingWorkBudget.WidenForDestinationHold): the time ceiling rises
from the authored 2 ms to an absolute 8 ms default
(ACDREAM_STREAM_WORK_HOLD_DEST_MS is a measurement-only override), every
count/byte dimension scales by the same factor so elapsed time stays the
authoritative guard (the measured binder is Time at both ceilings), and
the reserve fraction is re-derived (0.75 -> 0.9375) so the
NON-destination lane's absolute per-frame caps are unchanged. The
widening keys off the existing BeginDestinationReservation/
EndDestinationReservation bracket only, is derived per-Tick from the
CURRENT budget (mid-hold quality swaps compose), and a frame with no
reservation uses the authored budget verbatim (test-pinned). Portal
holds ride the same bracket as login holds by construction - intended,
and pinned by a kind-parity test through the real coordinator plus a
live @telepoi portal hold (kind=portal gate-ready 3589 ms).
Why: issue #418's next-hypothesis (1). Measured result: the ~5 s
publication drip collapsed to ~2 s (loaded 625/625 at ~3.0 s, tunnel at
64-66 fps), the portal-hold gate-ready fell to ~3.6 s - and login
gate-ready/total stayed at 8.4-8.8 s / 12.6-12.7 s, exposing the real
remaining pacer: the login-cold render-thread upload/registration
barrier behind GpuWorldState.IsRenderReady, which ran concurrently under
the old drip. Full attribution appended to docs/ISSUES.md #418; no
divergence-register row (the streamed result and reveal gate are
byte-identical; only the scheduling rate during a hidden hold changed).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The user's benchmark (retail ~6 s at a smaller window without our
complete-at-reveal guarantee) is now the issue's done bar. Next levers
in order: hold-scoped destination-lane budget widening (predicted
gate-ready ~2-3 s), then upload/composite overlap; retail's authored
tunnel exit is not a tuning target. Miss-branch closeout per the
session goal: acceptance measured 12689/12734 ms (> 12000), attribution
complete, no blind tuning.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The flat 32 blocks/s was the metered publication arm treating Runtime's
deliberate two-poll collision activation as end-of-frame: one landblock
per 32 Hz streaming tick with the 2 ms budget ~90% idle. The metered arm
now uses the same Runtime continuation gate the synchronous path always
used; the budget is genuinely authoritative, debt semantics preserved,
streamed result byte-identical. Remaining floor fully attributed in #418
(~8 s budgeted readiness + retail's authored tunnel exit).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Phase-1 measurement (new [publish-timing]/[stream-tick] probe surfaces,
ACDREAM_PROBE_REVEAL_TIMING=1) refuted the ~31 ms-per-admission
hypothesis: the hold runs at ~64 fps with the streaming tick at ~32 Hz,
the whole 625-block window costs only ~500 ms of publication CPU (far
blocks ~0.17 ms, near 2-43 ms), and steady state showed ZERO meter
yields with ~0.22 ms of the 2 ms budget used - yet exactly one block
published per tick against a ~400-deep completion queue.
The real limiter: Runtime's collision-generation activation is a
deliberate two-poll transaction (the first
TryAcquireCollisionPrefixMutationPermission poll parks residents and
refuses by design), and LandblockPresentationPipeline.Advance's metered
arm returned Completed=false on ANY nonterminal commit, which
DrainAndApply treats as end-of-frame. One landblock per 32 Hz tick =
the flat 32/s, with the authored budget ~90% idle.
Fix: the metered arm now uses the same Runtime-owned gate the unmetered
arm and the synchronous CompletePublication API always used
(CanContinueMutationSynchronously). The second poll runs in the same
frame under the same meter, so the unchanged 2 ms elapsed-time ceiling
is now genuinely the authoritative per-frame bound; with any real debt
(live residents parked mid-game, pending withdrawals, dispatch backlog)
publication defers to the next frame exactly as before. No budget
values change, no reveal-gate/readiness change, and the streamed result
is byte-identical - only the frame scheduling of identical operations.
Measured A/B (this binary, two runs): totalMs 12689 / 12734 vs baseline
26728/27395/27503; loaded slope 32/s -> bursts of 100-360/s, 625/625 in
~6-7 s vs ~23 s. The remaining ~12.7 s floor is fully attributed in
docs/ISSUES.md: ~8 s of real budgeted readiness work plus retail's
authored tunnel exit (TunnelContinue 2-5 s + two 1 s fades, golden
constants), so the <12 s acceptance needs a lead decision on the
hold-time budget, not another hidden limiter.
New regression pin:
MeteredLoaded_NonterminalCommitWithoutDebt_CompletesInOneMeteredAdvance.
Gates: Release build 0 errors; App tests 5576/3 skips/0 failed;
Runtime tests 1756/0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The identical flat 32/s across the serial and pooled binaries points at a
single ~31 ms indivisible per-landblock admission on the update thread
(one admission per frame under the 2 ms floor, frame stretched to ~31 ms,
~32 fps x 1/frame = 32/s); the old serial builder producing at the same
~31 ms/block masked it. Predicts ~31 ms tunnel frame time; fix shape is
splitting/off-threading the publication cost, not budgets (disproved
twice). Verify the frame-time prediction first next session.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Login publishes the 25x25 window at a flat 32 blocks/s (~27 s in the
tunnel). The reveal-timing probe A/B (695a27b4) showed the consumer
budget env ceilings change nothing, which was read as producer-limited:
one "acdream.streaming.worker" thread, ~31 ms/block. This replaces the
single worker with min(ProcessorCount-2, 8) workers, floor 1.
Design: striped/affinity dispatch. Each worker owns one unbounded lane
channel plus its own high/low priority queues; jobs route to
lane = ((id >> 16) * 2654435761) % N (the low word of a landblock id is
constant, so the id is mixed before reduction). Striping was chosen
over a shared queue + in-flight conflict tracker because it preserves
the per-landblock contract structurally rather than by bookkeeping:
every job for one id lives on one lane, so per-id enqueue order IS
execution and completion-arrival order, and the same-landblock
supersede rules (PromoteToNear removes queued LoadFar/Unload) keep
seeing every queued job for that id. Contract, point by point:
- Per-landblock ordering: same id -> same lane -> serial FIFO.
- ClearLoads: broadcast to every lane inside the same _inboxGate lock
that serializes enqueues, so any load enqueued before
ClearPendingLoads() returns sits ahead of its lane's ClearLoads copy
in that lane's FIFO and is dropped at read time, exactly like the
single-thread path. Already-dequeued builds still complete (now up
to one per worker instead of one total); StreamingController's
SweepCollapsed already unloads those uniformly.
- Priority: per-lane high/low split unchanged. Cross-lane, priority is
not globally ordered (a lane cannot run another lane's job), which
the contract permits; near-tier jobs hash-spread across lanes and
are preferred within each.
- Outbox: SingleWriter flipped to false; nothing assumed single-writer
(PublishResult already used TryWrite + an Interlocked backlog, and
the consumer's peek->read head-stability holds because only the
single reader ever moves the head). Cross-landblock arrival order
was verified arbitrary-tolerant before relying on it:
StreamingController.AdmitCompletions classifies each result
independently into per-priority FIFOs (generation staleness +
per-landblock retirement blocking); per-landblock arrival order is
preserved by striping.
- Crash surface: per-worker. The first real crash publishes
WorkerCrashed (prefixed "worker N:" in pools > 1), sets
_workerFailure, completes every lane, and cancels the pool (a crash
still ends all processing, as before); siblings that merely observe
the closed lanes (ChannelClosedException) exit quietly instead of
reporting spurious crashes; the outbox completes only when the LAST
worker exits so no in-flight completions are dropped.
- Disposal: joins every worker under the same _disposeGate; Start
stays idempotent and dispose-serialized.
Thread-safety audit of the production build closures
(SessionPlayerComposition), per shared object:
- DatCollection (every read in LandblockBuildFactory.BuildLocked:
LandblockLoader.Load, SceneryGenerator.Generate, SetupMesh.Flatten,
CellMesh.Build, GfxObjBounds.Get, GfxObjDegradeResolver): NOT
thread-safe; already serialized under the shared _datLock, which
BuildLocked holds for the whole read transaction. Unchanged; the
probe run measured hold 0-13 ms / wait <= 12 ms during the login
window, so the lock is not the new bottleneck and the build was NOT
serialized beyond it.
- PakPreparedAssetSource / PakReader (BuildPreparedCollisionClosure,
outside the lock): immutable TOC array + read-only
MemoryMappedViewAccessor random-access reads + ConcurrentDictionary
verdict caches - safe for N concurrent readers (Slice I3 design;
the headless SharedPreparedCollisionCache wrapper is fully
lock-protected).
- LandblockMesh.Build (outside the lock): pure math over the dat
record + the composition-time height table + the immutable
TerrainBlendingContext record; the shared SurfaceCache is a
ConcurrentDictionary and BuildSurface is deterministic, so its
lookup-or-build race is last-write-wins-benign (the code already
documented exactly this).
- PhysicsDiagnostics probe statics: read-only bools + thread-safe
Console writes.
MEASURED OUTCOME (gate 4): the timing acceptance did NOT pass, and per
the task contract that is reported, not tuned around. With 8 workers
on this 16-core machine all 625 builds complete in ~203 ms
(ACDREAM_PROBE_TELEPORT BUILD lines t=3475390..3475593) - the producer
is off the critical path - but loaded= still advances at exactly
+32/1000 ms and SUMMARY totalMs measured 27395 and 27503 across two
runs (baseline 26728). The 32/s pacer is in the consumer
admission/publication path and is not governed by the
StreamingWorkBudgetOptions env ceilings. #418 stays IN-PROGRESS on the
consumer side; see docs/ISSUES.md for the evidence chain.
Tests: per-landblock ordering under 4-worker contention, cross-lane
ClearLoads drop, per-lane near-before-far preference, pool-of-1 serial
equivalence, disposal joining every worker, lane-spread guard, and
worker-count validation (LandblockStreamerPoolTests). Two existing
tests asserted a GLOBAL cross-landblock execution order - a serial
implementation detail, not the contract - and now pin workerCount: 1
with justification comments (LoadNear_OvertakesQueuedFarLoads,
TwoQueuedLoads_RetainTheirDistinctOriginAndGeneration).
Gates: Release build 0 errors; App suite 5575 passed / 3 skipped
(5568 + 7 new); Runtime suite 1756/0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
[reveal-timing] lines from the reveal coordinator: per-dimension
first-ready edges (render neighborhood, composite textures, collision,
gate, materialization), 1 Hz progress with the resident-landblock count
(new GpuWorldState.LoadedLandblockCount), and one SUMMARY line at the
viewport reveal. Measurement-first groundwork for the login-load speedup:
the readiness barrier observes its dimensions serially, so the edges give
each dimension's observed tail while the progress lines expose the
pacing shape (a budget-paced linear drip reads directly off the counts).
Probe-gated in StreamingDiagnostics per Code Structure Rules §5; no
behavior change, one branch per Evaluate poll when unset.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The character-session reset manifest had no audio step: retail's logoff
destroys the world's sound sources with the world, but our OpenAL world
pool and ambient scheduler are process-lifetime — the continuous ambient
beds played on at character select and the scheduler kept RE-FIRING
deadlines against the stale listener (Suspend/StopAll had zero callers;
WorldGenerationQuiescence only cycles around teleport-style generation
replaces).
New WorldAudioSessionGate: the reset manifest's 'world audio' step stops
all sixteen world-pool voices (SuspendWorldAudio) and drops every ambient
deadline (StopAll); the pool reopens at the entered-world edge through the
new default-null LiveSessionEnteredWorldBindings.ResumeWorldAudio binding,
invoked first in ApplyEnteredWorld. The ambient soundscape needs no
explicit resume — the next objcell observation rebuilds it exactly as a
cell change always did. Covers logout, reconnect, and full stop uniformly.
UI-pool sounds (interface bank, portal cues) untouched by design.
App tests 5568/3 skips, Runtime 1756/0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The retail button state/media machine ported from the decomp
(UpdateState_ authored-state gate, SetState state-0 arm, the
non-empty-media reset rule) replaces the media-keyed approximation that
latched the character-select roster highlight. Live-verified. The probe
wait verbs now bind a facts-only automation runtime in every launch.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
#416 (char-select roster highlight never cleared on hover-leave): three
decomp-grounded mechanisms replace the media-keyed _availableStates
approximation.
- UIElement_Button::UpdateState_ @0x00471CF0: the button machine commits
ONLY states authored on the button's OWN ElementDesc (AccessStateDesc
gate); unauthored requests no-op, preserving custom semantic states.
- UIElement::SetState @0x00464E70: an unauthored state id is coerced to
state 0 (the unnamed base state) and committed — ported into
UiDatElement.TrySetRetailState with the base-descriptor PassToChildren
cascade arm.
- The SetState media rule @0x004651c0: a committed state replaces the
playing media ONLY when its media array is non-empty. UiButton now keeps
per-face-segment media states under that rule (segments model retail's
PassToChildren children), and LayoutImporter records the raw MediaCount
including the File=0 draw-nothing images the drawable filter drops —
the roster bar children's base state is exactly such an image, and it is
what clears the bar.
The row template truth (probe, installed DAT): the row authors EMPTY
Normal/rollover/Highlight descriptors with PassToChildren; the three bar
children author rollover/Highlight media, NO Normal state, and a File=0
base image. An empty-media Normal_pressed still never blanks a Normal-art
button (the media rule keeps the previous art — the exact behavior the
old gate approximated), and the Appearance spins' property-only Highlight
now genuinely commits: label recolors, arrow art lingers — the retail
split AP-222 approximated with a requested-keyed label hack, now retired.
Live-verified at char select: hover +alex shows the grey bar, moving off
clears it, the selected row keeps its amber bar.
#415 (probe wait world-* verbs dead): the filed snapshot-reset diagnosis
was wrong — the automation bridge simply never bound without
ACDREAM_AUTOMATION_ARTIFACT_DIR. A facts-only
WorldRevealFactsAutomationRuntime now binds whenever the retained UI
exists; checkpoint/screenshot verbs still require the artifact directory
and now report that instead of a generic timeout.
App tests 5568/3 skips, Runtime 1756/0, UI.Abstractions 926/0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Teardown's dev fly-camera fallback raw-captured the OS cursor on the
character-select screen; teardown now lands on the orbit camera (the
fresh-boot state) and the pointer controller restores a normal cursor.
Live-verified both directions under GetCursorInfo sampling. Files #415
(broken 'wait world-visible' automation verb, apparatus only).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Session teardown (PlayerModeController.Exit/ResetSession ->
CameraController.ExitChaseMode) fell back to the dev free-fly camera, and
CameraPointerInputController.ApplyCursorForCameraMode faithfully applies
CursorMode.Raw (GLFW disabled cursor: hidden + captured) for fly mode —
so the character-select screen after an in-world logoff had no mouse.
Fresh boot starts in Orbit and never fires a mode change, which is why
only the post-logout path was affected.
Teardown now lands on Mode.Orbit — the exact state a fresh boot presents
at character select — and always notifies, so the pointer controller
restores CursorMode.Normal even when torn down from the dev fly camera.
The dev fly<->chase flow is untouched (it rides ToggleFly, never
ExitChaseMode).
Proven live both directions with a driven logout (UI probe 0x100000FA ->
dialog accept 0x17) under Win32 GetCursorInfo sampling: before, flags
flipped 1->0 exactly at the roster re-push that re-shows character select
and stayed hidden; after, zero hidden samples across the full timeline.
Files #415: the UI-probe 'wait world-visible' verb reads the reset
transit snapshot and is dead after reveal completion (test apparatus
only).
App tests 5564/3 skips (+3), Runtime 1756/0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Tunnel arms at the Enter click (user-directed improvement over retail's
black CreatePlayer gap; AD-109). The full retail logoff: 0xF653 with the
3s hold (+20s PK), server-driven LogOut motion with input disabled,
the wormhole entered in reverse order (no exit cue), live return to
character select on the kept connection with the pushed roster —
second Enter round-trips (AD-110 filed, AD-74 RETIRED: Options'
Exit-to-Character-Selection is now real). Escape normalization moved to
retail's own placement — StringTableMetaLanguage::UnescapeString ported
at the string source, five consumer patches retired, the 4,365-string
escape population sweep-pinned (AD-111 records the wire-domain
appraisal exception).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Retires AD-74 (Exit to Character Selection 'behaves as Exit Game') and
files AD-110 (the composed handoff edge) — register rows in this commit.
Retail derivation (named decomp):
- gmGamePlayUI::UseTime @0x004EA3A0: confirmed Yes drains into
CPlayerSystem::LogOffCharacter(0) when grounded (transient_state &
CONTACT); the grounded three-way branch now also covers the
indicator-bar end-session control (it was Options-only).
- CPlayerSystem::LogOffCharacter @0x00563520: SaveToServer FIRST (the
existing pre-logoff flush hook), then RequestLogOff @0x00562DD0:
'Logging off...' chat (type 0), 0xF653 via Proto_UI::LogOffCharacter
@0x00546A20, logOffRequestTime = now + 3.0 (+20.0 when
IsPlayerKiller @0x0058C910 — PWD bits 0x20|0x2000000), and
CommandInterpreter::HandleLogOff @0x006B3330 -> Disable.
- The log-off ANIMATION is server-driven: ACE broadcasts
MotionCommand.LogOut (0x1000011E, Player.cs:596 SendMotionAsCommands)
and it plays on the local player through the existing inbound
unpack_movement funnel during the 3 s hold — retail plays nothing
locally; Disable() is the whole client-side effect.
- gmSmartBoxUI::UseTime @0x004D6E64: hold elapsed ->
BeginTeleportAnimation(TAS_WORLD_FADE_OUT) @0x004D6E83 (enter cue
@0x004D638E, unconditional) -> TunnelFadeIn -> Tunnel. The tunnel
plays the SAME forward 40 fps animation; nothing renders backwards,
and NO exit cue ever fires on logout (the char-select swap preempts
the TunnelContinue/FadeOut tail).
- Inbound 0xF653 echo (dispatch case 3 @0x0055C963) ->
ExecuteLogOff @0x0055D780: world teardown with the LOGON CONNECTION
KEPT (ExitWorldDisconnect @0x00541E00 removes every connection
except logonRecID_ — one connection against ACE) and
Proto_UI::SetEventCounter(0) @0x00541E79; the fresh CharacterList in
the same batch re-shows character management (gmGamePlayUI::Update
@0x004E9CD0 -> QueueUIMode(0x1000000a)). ACE mirrors it:
SendFinalLogOffMessages (Session.cs:249) sends 0xF653 + CharacterList
+ ServerName >=6 s after the request and leaves the session
AuthConnected — a second EnterWorld needs no re-handshake.
Implementation:
- RuntimeWorldTransitState: the canonical logout lifecycle
(Requested/PresentationActive/Confirmed, retail 3 s/+20 s holds,
cancel/reset/ownership convergence).
- WorldSession: RequestCharacterLogOff (non-blocking 0xF653),
IsCharacterLogOffConfirmed, ReturnToCharacterSelect (InWorld ->
InCharacterSelect + game-action sequence reset; transport untouched).
- LiveSessionController: BeginCharacterLogOff (flush-first request) and
CompleteCharacterLogOff — the return-to-selection transaction
(ReconnectCore minus the transport swap: retire the world
generation's routes, host reset, state flip, fresh generation
re-bind, roster re-applied from the pushed CharacterList; failures
degrade to the full StopCore teardown).
- RuntimeLocalPlayerMovementState.DisableCommandInterpreter +
DispatcherMovementInputSource gate: retail's Disable() — held keys
produce no movement while the server LogOut motion plays; cleared by
the generation reset.
- LocalPlayerTeleportController: the logout pump as the third arm of
the one wormhole machine (request/hold/wormhole/confirmed handoff;
teleport starts refused during logout; the handoff runs the session
transaction whose world reset retires the tunnel as the fresh
selection state re-shows the character screen).
- UI: both end-session surfaces share the retail three-way grounded
gate and now run the REAL flow; Options' Exit Game keeps the app
exit (window close -> the existing graceful-shutdown logoff).
Tests: +5 transit lifecycle, +4 session transaction, +7 logout pump.
Runtime 1756/0 (baseline 1747), App live-DAT 5523/3 (baseline 5512/3
+ 11 this round), Core.Net 1004/0, full solution green (0 failures).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
USER-DIRECTED deviation from retail (register row AD-109, same commit):
retail presents the empty pre-player gameplay screen — black behind the
retained UI — from the Enter click (CPlayerSystem::LogOnCharacter
@0x0055F890 -> CM_Login::SendNotice_BeginEnterWorld @0x006AD810, UI mode
0x10000008) until CreatePlayer raises SmartBox::teleport_in_progress
@0x00451C20 and gmSmartBoxUI::UseTime @0x004D6EAB begins TAS_TUNNEL. The
user prefers the tunnel to cover that whole wait.
- ILocalPlayerTeleportNetworkSink.ArmLoginTunnel: begins the login
wormhole presentation at the Enter click, consuming the sequencer's
begin-edge events SYNCHRONOUSLY (the Enter command blocks the update
thread for the whole ServerReady round trip, so a deferred first tick
would leave exactly the black window this deviation removes). The
enter cue plays at the click: retail's own rule is cue-at-animation-
begin (Sound_UI_EnterPortal @0x004D638E, unconditional inside
BeginTeleportAnimation), and the animation begin moved to the click.
- Armed pre-reveal pump: tunnel animates across the round trip
(worldReady pinned false, sequencer holds in Tunnel); the hold clock
accumulates from the click.
- Adoption: the Runtime login reveal ADOPTS the running presentation
(no re-Begin, no second cue); rejected EnterWorld (lifecycle back to
AwaitingSelection) disarms and retires the tunnel.
- Wired at the ONE host edge every entry route shares:
ILiveSessionLifecycleHost.ApplySelectedCharacter (direct connect,
roster Enter, enter-after-create) via
LiveSessionSelectionBindings.ArmLoginTunnel (default no-op keeps
headless and every existing construction site unchanged).
- ILocalPlayerLoginLifecycleSource: typed seam (not a stored delegate —
the frame-phase owner delegate-field guard) projecting the Runtime
character-selection lifecycle for the disarm edge.
- Frame contract update: [login-frames] over a login is tunnel -> world
from the click — no void, and no black between click and world.
Tests: 4 new armed-tunnel tests (arm/adopt/disarm/frame-shape); App
suite live-DAT 5516 passed / 3 skipped (baseline 5512/3 + 4 new).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Found during the systemic escape-normalization round (967b9c57): retail's
ItemExamineUI::AddItemInfo @ 0x004AC050 appends wire strings straight to
UIElement_Text::AppendTextWithFont with no unescape — the escape decode
belongs exclusively to StringInfo resolution, which that round ported to
DatStringResolver/RetailStringEscapes. ItemAppraisalTextLayout.Shape's
literal-"\n"-to-line-break replace on server fragment text is therefore a
deviation, pre-existing inside the user-accepted assessment surface, now
filed instead of living only in a code comment.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The exit-world confirmation (ID_Client_EndCharacterSessionConfirm, table
0x23000001 key 0x0EB1C41D) rendered its literal two-character "\n" escapes
because escape decoding lived in individual consumers — Batch E centralized
it for authored captions only (DatWidgetFactory.ResolveAuthoredString), and
each new string surface had to remember its own copy. The installed DAT
carries the escape in 4,365 of 7,050 strings; per-consumer normalization
was structurally guaranteed to keep leaking.
Retail's placement is the SOURCE, not the widget: every public StringInfo
resolution ends in StringTableMetaLanguage::UnescapeString @ 0x0067BDC0
(StringInfo::InqString @ 0x0042E490, GetLiteralValue @ 0x0042CA50), the
write side escapes (SetLiteralValue @ 0x0042C980; AddVariable_String
@ 0x0042E6C0 for template variables), and widgets receive decoded text.
Ported exactly:
- NEW RetailStringEscapes: UnescapeString/EscapeString + the
GetUnEscapedChar @ 0x0067B750 / GetEscapedChar @ 0x0067B6C0 tables
(\n \t \r \q + the ten metalanguage self-escapes []!{}#\|^$,
byte-verified against the PDB-paired 2013 binary at 0x3FE178;
unrecognized pairs stay verbatim).
- DatStringResolver.Resolve/ResolveAll unescape at the source;
ResolveTemplate escapes each variable on insert and unescapes the
composed whole — retail's round trip, so variable content (player
names) can never be corrupted by the final decode.
- RETIRED the consumer copies (double paths would corrupt an authored
"\n" into a line break): DatWidgetFactory.NormalizeEscapes + BuildText's
inline replace, RetailUiRuntime.NormalizeRetailNewlines + the
OpenCaptureInstructions inline replace, DatRichText.Compose's replace,
IndicatorDetailText.Shape's replace. ItemAppraisalTextLayout's replace
stays — WIRE-domain (server strings never pass the DAT source; retail's
ItemExamineUI::AddItemInfo @ 0x004AC050 appends wire text verbatim), now
documented as such.
- Consumer CR-strips retired with them: the installed DATs contain ZERO
real CR characters (sweep-measured) and UiText.WrapWords already drops
strays.
Tests: RetailStringEscapes conformance (escape set, unknown pairs,
round trip), DatStringResolver source-decode pins (including the exact
user-reported exit-world text shape and a backslash-carrying variable),
the installed-DAT escape sweep (7,050 strings; every resolution must equal
the retail unescape of the raw entry; inventory printed), and the existing
caption/rich-text/live-DAT pins relocated to the source contract.
App 5550/3 (live-DAT), Runtime 1747/0, complete Release solution green
across all suites.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The login tunnel now covers from the first world-facing frame (the
sky-void backdrop can never present pre-tunnel) and holds through an
atomic tunnel-to-world swap at reveal completion — the void is
structurally unreachable on both edges, pinned by frame-sequence tests
across WorldSceneRenderer/WorldRevealCoordinator/LocalPlayerTeleport-
Controller/RuntimeWorldTransitState. Vitals detail icons draw at their
authored centered offsets in both stacked and side-by-side layouts.
Implemented and live-probed by the fix agent; finalized by the lead
after the agent parked post-verification (gates re-run green:
App 5512/3, Runtime 1747/0).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Vitals: click toggles numeric/graphical per gmVitalsUI's exact state
machine (HideDetail/ShowDetail cascade, authored icon pairs with clipped
fill, first-click no-op retail quirk); Side By Side Vitals as retail's
two-window visibility swap on CharacterOptions1 bit 0x00200000, live on
option change, blob-persisted. Login portal-space: every world entry
(direct, char-select Enter, enter-after-create) now runs retail's
wormhole with Sound_UI_EnterPortal via the teleport_in_progress flag
edge; LoginComplete moved to retail's fade-in-end timing; TS-28
narrowed; a latent pre-publication portal-space crash fixed; the UI
probe's canvas-vs-window coordinate bug fixed. All live-verified on
both accounts with screenshots + cue evidence.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The vitals round's connected verify needed to click ONE specific
Character-tab option row, but every toggle row is a template instance
sharing the same dat element ids (0x10000218/0x10000219), so
`click element` (first-match by dat id) cannot address a row. The drive
script now reads the row's rect from its own `dump` line and clicks its
center — same synthetic UiRoot press/release route as ClickElement, never
the OS cursor (the same no-real-input constraint the morning gate's
hover/mousemove verbs follow).
Used live: the Side-By-Side Vitals checkbox + Apply choreography that
verified db8fa328's swap both directions over a real ACE session.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The automation probe fed element-center CANVAS coordinates straight into
UiRoot.OnMouseMove/Down/Up, which take WINDOW coordinates and map
window->canvas internally (UiRoot.MapWindowToCanvas). The two spaces are
identical on every screen without UiRoot.FixedCanvasSize — every prior
probe gate passed — but the character-select/chargen screens stretch an
authored 800x600 canvas across the window, so every synthetic click and
hover landed at canvas*(canvas/window): nowhere near the target. The
enter-world connected gate's 'click element 0x100003A2' silently did
nothing for two full rounds. Element-derived pointer paths (ClickAt,
DragAt, HoverElement) now convert canvas->window via UiRoot.CanvasScale;
raw 'mousemove x y' stays a passthrough.
CharacterManagementUiController.EnterSelected also logs a once-per-click
outcome line ('[UI] character enter accepted/rejected status=...') — a
refused Enter was previously indistinguishable from a click that never
dispatched (both silent), which cost a connected-gate round to tell
apart.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Retail runs the SAME TAS_TUNNEL wormhole at initial login as at an F751
teleport, with no F751 involved: SmartBox::teleport_in_progress
@0x00451C20 returns 1 the moment the login player exists with
position_update_complete == 0, gmSmartBoxUI::UseTime @0x004D6EAB
edge-detects it into BeginTeleportAnimation(TAS_TUNNEL) @0x004D6EC9
(playing Sound_UI_EnterPortal @0x004D638E), SmartBox::UseTime
@0x00455483 ends the hold once destination cells stop blocking,
Sound_UI_ExitPortal plays at the viewport swap @0x004D7405, and
LoginComplete goes out at the WorldFadeIn end @0x004D745D ->
CPlayerSystem::SendLoginCompleteNotification @0x00562E90 (ACE's own
GameActionLoginComplete comment names this contract: 'called when the
client player exits portal space. It includes initial login'). acdream
skipped all of it at login — every entry route (direct auto-select,
character-select Enter, enter-after-create) dropped onto the sky-only
'waiting for login' backdrop until the world reveal completed.
The fix engages the EXISTING F751 presentation machinery on Runtime's
login reveal — no duplicated presentation code, no timers:
- LocalPlayerTeleportController gains a login arm keyed off the
Runtime-owned login reveal generation (RuntimeWorldTransitState
.BeginLoginReveal, begun on the first accepted local-player position
on every entry route). It drives the same TeleportAnimSequencer/
PortalTunnelPresentation lifecycle and the same enter/exit cues; the
Place edge is a no-op at login (the first-entry conductor already
committed the canonical placement — retail's analogue only flips
position_update_complete), and FireLoginComplete now performs
EnterWorld + the single LoginComplete send + reveal Complete, exactly
like the F751 pump. worldReady is latched on BOTH canonical first
placement (OnLocalPlayerFirstEntryCompleted, the repointed
GraphicalSessionEventRoute completion callback that used to send
LoginComplete immediately) AND destination reveal readiness.
ActiveDestinationCell now also reports the login destination so the
render frame's reveal-preparation arm keeps running after portal-space
entry flips ChaseModeEverEntered.
- PlayerModeController.TryEnterPortalSpaceForLogin performs the
player-mode presentation attach (the same BuildControllerAndCamera the
post-reveal auto-entry used to run) before flipping into portal space
— at login no player-mode entry has happened yet. TryEnterPortalSpace
itself now refuses (retryable) on a constructed-but-unpublished
Runtime controller via the documented CanExecuteLiveMovement skip
predicate instead of faulting — the first connected run crashed on
exactly that pre-publication State write.
- HouseQuery stays at first-entry completion (retail: tail-called from
CPlayerSystem::InitializePlayer @0x00563570, an object-arrival edge,
not a tunnel edge).
- An F751 arriving mid-login-tunnel withdraws the login claim and hands
the presentation to the portal pump, which owns the single
LoginComplete — matching retail's one teleportInProgress flag.
TS-28 narrowed: the graphical host now runs the full login wormhole;
the residual is headless-only (no presentation; placement-edge send).
Live gates (testaccount2/+Horan vs local ACE, Release): the
character-select Enter route and the --session-config direct auto-select
route both play the wormhole with Sound_UI_EnterPortal at animation
begin, hold with retail's 'In Portal Space - Please Wait...' notice
until readiness, fade out with the view-plane warp, send LoginComplete
at the WorldFadeIn end, and materialize in Holtburg; ACE-confirmed
graceful logout. Tests: App 5493/3 skips (baseline 5490 + 3 new login
tests), Runtime 1744/0, full solution green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two end-to-end additions to the fixture toggle suite: a body press through
UiRoot.OnMouseDown's actual hit test + bubble (left toggles, right toggles),
and a press on the authored top drag bar (0x1000063C) arming the window
move WITHOUT toggling — the retail Dragbar-consumes-the-press semantics
proven against the real input path, not just injected OnEvent calls.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The press toggle returned false directly, silently swallowing the base
class's Click dispatch (OnClick/OnClickAt) for any future controller wiring
on a vitals root. Retail's handler falls through to the base listener the
same way (@0x004BFC47). No behavior change today — nothing sets OnClick on
a vitals root.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Bookkeeping for the vitals retail-modes round (306a1670 + db8fa328): the
side-by-side vitals row joins IA-15's production LayoutDesc import list.
No new divergence class — the window shell, layout persistence, and
whole-surface drag regions the two vitals windows ride are already
registered under IA-12/IA-15/AP-98.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Port of retail's SideBySideVitals character option, derived end-to-end:
retail authors TWO complete vitals windows and swaps their VISIBILITY on
the option bit — nothing is rearranged in place.
- gmFloatySideVitalsUI (0x10000056, Register @0x004D0490) is a second
full vitals window from LayoutDesc 0x21000075: 460x26, the same three
meters (0x100000E6/EC/EE), cur/max labels (0x100000EB/ED/EF), and
detail-icon overlays authored id-for-id with the stacked window — so
the same VitalsController.Bind and the inherited UiVitalsRoot click
toggle apply unchanged. Authored constraints ride the root's
0x3C..0x3F (fixed 26 height, width 360..3000) through
DatConstraintSource.
- Visibility ownership: gmFloatyVitalsUI::UpdateFromPlayerModule
@0x004CF140 shows the stacked window iff PlayerModule::SideBySideVitals
== 0; gmFloatySideVitalsUI::UpdateFromPlayerModule @0x004D0810 shows
the side row iff set; gmGamePlayUI::RecvNotice_PlayerOptionChanged
@0x004E9DA0 flips both live on option id 0x13.
- The bit: PlayerModule::SideBySideVitals @0x005D3070 =
(options_ >> 0x15) & 1 — CharacterOptions1 0x00200000, ACE-confirmed;
CharacterOptionTable already carried the exact row (PlayerModule-blob
group, not a 0x0005 auto-save id).
acdream shape: MountSideVitals mounts the second window hidden;
VitalsSideBySideController polls the borrowed J4 option bit once per
frame from RetailUiRuntime.Tick and applies BOTH windows' visibility on
the edge — covering the mount default, the PlayerModule blob arriving
after mount, and the Character tab's live checkbox with one mechanism.
Both window names join stateManagedVisibilityWindows so the saved layout
never restores a visibility the option owns. The Character tab's
SideBySideVitals row un-dims (StoreOnly → Live) with a real reader —
33 dimmed / 17 live.
4 new controller tests (initial apply both directions, live edge swap
both directions, steady-bit non-reassertion). App suite Release live-DAT
5499 passed / 3 skips; Runtime 1744/0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Port of gmVitalsUI's press toggle, derived end-to-end from the named
retail decomp + the authored DAT data (installed-DAT probe 2026-08-17):
- gmVitalsUI::ListenToElementMessage @0x004BFC00: mouse press (msg 0x1C,
dwParam1 7=left or 0xA=right — the same param pair the spellbook's
select/favorite handler @0x0048C033 disambiguates) flips
SetState(m_state == HideDetail ? ShowDetail : HideDetail). Both floaty
subclasses (gmFloatyVitalsUI 0x1000004D / gmFloatySideVitalsUI
0x10000056) inherit it verbatim.
- UIElement::SetState @0x00464E70 cascades through the authored
PassToChildren chain: root and meters author media-less
HideDetail/ShowDetail StateDescs with PassToChildren=true.
- HideDetail (0x10000006) = the NUMERIC mode: the cur/max labels author
{0x3B:false} (0x3B = invisible; UIElement::OnSetAttribute case 8
@0x00462DAE is SetVisible(value == 0)), the 0x100004A9 overlays author
File=0.
- ShowDetail (0x10000007) = the GRAPHICAL mode: labels author {0x3B:true}
(numbers hidden); each bar shows its authored icon pair — dim back icon
unclipped over the track, bright front icon clipped with the front
container to the fill fraction (UIElement_Meter::DrawChildren
@0x0046FBD0 clips the whole element-id-2 child; m_pcChildImage =
GetChildRecursive(this, 2) @0x0046F7E3). Health heart 0x06007490/91
(18x16 @66,0), stamina sword 0x06007492/93 (85x16 @32,0), mana scepter
0x06007494/95 (100x16 @25,0) — identical authoring in both 0x2100006C
and 0x21000075.
- Initial state is the authored Undef (numbers visible, no icons —
visually HideDetail); retail's first press lands on HideDetail, then
the pair toggles forever. NOT persisted: SaveScreenLayout @0x004EAD50
writes window rects only, and no PlayerModule option is touched — the
mode resets per session, per window.
- Presses on drag bars / resize grips do not toggle: retail's
UIElement_Dragbar @0x0046C850 and UIElement_Resizebar @0x0046B930
consume the press (return 2) before it can bubble to the root.
Implementation: new UiVitalsRoot behavioral widget registered for the
three gmVitals class ids (press handler + state flip over the existing
UiDatElement state machine); UiMeter absorbs the two 0x100004A9 overlays
(ConfigureDetailOverlay + ShowDetail-keyed draw, back unclipped / front
fill-clipped) and forwards the detail states to its absorbed text child;
UiText.ApplyDatState gains the same named-state-only 0x3B honor
UiDatElement already had (the DirectState 0x3B class stays gated — #408).
8 new fixture-driven conformance tests (toggle sequence, right-press,
label cascade, chrome exclusions, per-window independence, overlay
extraction). App suite Release live-DAT: 5495 passed / 3 skips.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The instant world-tooltip path is drag-only (@0x004E5D8E gate) — ordinary
hover stages the name and rides CheckTooltip's mouse-idle dwell; ported.
DisplayBuyPayment emits in BOTH branches — houseless push 0x7ab688 = 'You
do not currently own a house.' (BN vftable-mislabel hid it); the tab now
shows retail's two lines. The map hotspot template authors its own popup
locator (fourth skin 0x10000398, font 0x40000015, P0x50=0 instant) plus a
pure-green highlight frame via PassToChildren state cascade — override
removed, cascade ported (named-states-only P0x3B honor, #408-scoped).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Morning gate live-verify apparatus: 'hover element <datId>' and
'mousemove <x> <y>' drive UiRoot.OnMouseMove synthetically (no click, no
real cursor theft — a user is present at the machine during this round,
unlike the overnight rounds whose drive scripts moved the physical
cursor). Deliberately no probe-clock Advance: hover dwell and the
world-tooltip timing must ride the production frame tick's real
monotonic clock, which keeps running between script commands — an
Advance would stamp the idle timestamp with the probe's tiny private
counter.
(Committed from a re-attached worktree: the round's original worktree
was pruned from git's registry mid-session by an external cleanup; the
branch and all three finding commits were unaffected.)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
User finding 3 (retail screenshot): hovering a town on the Map tab turns
its marker GREEN and shows the name on a special-font tooltip — clearly
not our generic 0x10000395 popup skin, and we had no hover highlight at
all.
Re-derivation (live-DAT probe + raw ElementDesc dump + surface
byte-decode; MapNoteLiveDatTests pins all of it):
- m_pMap (0x100001EC)'s P0x47/P0x48 = 0x100001F0 @ 0x21000026 are the
note CONSTRUCTION template (AddMapNote @0x004a1bb0's
CreateChildElement args) — that part we had right.
- The TEMPLATE's own DirectState authors the note's tooltip popup
locator P0x47=0x10000398/P0x48=0x21000041 — the FOURTH popup skin,
whose incorporated text child 0x10000396 fonts 0x40000015 where the
other three skins font 0x40000002 (the user's "special font") — plus
P0x50=0.0 (zero per-element tooltip delay: town tooltips fire the
instant the dwell arms; UiRoot already honors it), P0x4B TooltipOn,
and P0x13 RolloverEnabled. Batch C's "the template authors no locator
of its own" claim was WRONG, and BuildTownMarkers' hardcoded
shared-skin override was clobbering the authored values — removed.
- The hover highlight: the template's Normal/Normal_rollover states are
PassToChildren descriptors driving the swallowed highlight child
0x100001F1 (base 0x100002B7@0x21000042 — a four-piece frame all
drawing 0x06004CC9, byte-decoded PURE GREEN A=FF R=00 G=FF B=00) via
per-state P0x3B (Invisible): hidden at rest, green on rollover.
Port:
- UiButton.CascadeStateToChildren — retail UIElement::SetState
@0x00464E70's PassToChildren cascade, keyed off the REQUESTED state id
(properties commit unconditionally; only the sprite draw is art-gated,
the existing #382/AP-222 distinction).
- UiDatElement.TrySetRetailState honors per-state P0x3B for NAMED states
(OnSetAttribute @0x00462d80 case 8: SetVisible(value==0)). The
unnamed-DirectState case is explicitly excluded — honoring it would
un-gate ISSUES #408 (1,083 authored-invisible elements) through
BuildWidget's post-children state reapply; measured breaking the
spell-favorite drag tests before the scoping (note added to #408).
- MapPageController.BuildTownMarkers rebuilds the button-swallowed
highlight child per marker through the AD-108 IconBuilder seam
(Bindings.TemplateInfoResolver, backed by
RowTemplateResolver.ResolveInfo — same cache) and arms it with the
initial Normal cascade.
Register TS-85's Batch C paragraph corrected; RetailTooltipPresenter's
F10 shared-skin remark updated (MapPageController no longer a consumer).
Tests: 3 installed-DAT pins (locator/delay/rollover; per-state P0x3B +
green frame; the four-skin font sweep), UiButton cascade + UiDatElement
P0x3B units, MapHousePanel marker no-clobber + hover-highlight fixture.
App suite 5487 passed / 3 skips (5490 total, +11 over baseline);
Runtime 1744/1744.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
User finding 2 (retail screenshot, houseless character): the House tab
shows "You do not currently own a house." ABOVE "You may buy another
house immediately." — ours showed only the second line, and the prior
session had REFUTED the first line outright ("no such string exists
anywhere in the 2013 dump").
Re-derivation: the string exists in the binary at data_7ab688 — it is
gmHouseUI::DisplayBuyPayment @0x004a2b30's HOUSELESS branch. Two
compounding misreads hid it: (a) DisplayBuyPayment was mislabeled
houseless-silent, but its m_pHouseData gate only selects WHICH text
(jne 0x4a2b63) — the ListBox emit (@0x004a2b80 onward,
AddItemFromTemplateList + SetTextWithFont) runs in BOTH branches; and
(b) BN's pseudo-C renders both push-literal operands as spurious
&vftable.RecvNotice_* symbol matches (the TS-85/F3 artifact class), so
text sweeps of the dump find nothing — capstone byte-decode of the
PDB-paired binary resolves houseless @0x004a2b57 push 0x7ab688 =
"You do not currently own a house." and owned @0x004a2b63 push
0x7ab65c = "The purchase price for this dwelling is:\n" (+
HousePaymentList::ComposeText, still #413 item-3 scope). The morning
brief's alternate DAT-string-table hypothesis was checked and is NOT
the mechanism — plain exe string-pool literal.
RuntimeHouseState.Recompute now renders the houseless case as retail's
exact two lines in gmHouseUI::Update's fixed builder order
(DisplayBuyPayment first, DisplayPurchaseTimeText last); the owned case
is unchanged (its DisplayBuyPayment content needs ComposeText, #413
item 3). Class doc + ISSUES #413 corrected honestly — the user's retail
evidence supersedes the earlier refutation. Runtime house tests updated
to pin both lines; 9/9 pass.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
User finding 1 (side-by-side vs retail): our world-object tooltips popped
the instant the found object changed; retail's "lag". The night round's
derivation from RecvNotice_SmartBoxObjectFound @0x004E5AD0 misread the
notice as edge-MOUNTING: its immediate StartTooltipAtMouse @0x004E5DFB is
inside `if (s_pInstance->m_dragElement != 0)` (@0x004E5D8E) — and
m_dragElement is a real, distinct PDB field in acclient.h's
UIElementManager (separate from the m_pTooltipElement family), so the
immediate mount is DRAG-AND-DROP ONLY. The ordinary hover path merely
STAGES the name (SetTooltip @0x004E5D74 + the |=0x20 TooltipOn bit) and
the display rides the SAME UIElementManager::CheckTooltip @0x0045B6E0
mouse-idle dwell as UI tooltips: 250 ms (m_tooltipDelay @0x0045f75d)
since m_lastMouseMoveTime (stamped on EVERY move, MouseMoveHandler
@0x0045e736). Found swaps under an IDLE mouse replace the popup the same
frame (SetTooltip's own text-change teardown @0x004617FF -> ResetTooltip
@0x0045C360 tail-calling CheckTooltip); the 10 s duration expiry
(@0x0045b78a) requires a fresh mouse move before re-arming
(SwitchMouseOver(null) @0x0045b7b2 clears m_pElementLastEntered).
Port: UiRoot gains the unconditional last-mouse-move stamp
(m_lastMouseMoveTime 1:1 — the existing _hoverStartedMs stamps are
deliberately conditional) exposed as MouseIdleMs/NowMs;
RetailTooltipPresenter.UpdateWorldHoverTooltip now stages text at the
notice edge (ShowTooltips gate + name resolve read there, @0x004E5D21/
@0x004E5D3B, empty-name SetTooltip skip @0x004E5D48 included) and mounts
via the CheckTooltip dwell block (no-capture gate @0x0045b715,
m_tooltipEnable via MouseHover @0x0046254C — which the drag-immediate
branch faithfully bypasses). Session reset also forgets the staged text.
Tests: the world-hover fixture section rewritten to the corrected model —
found edge stages but never mounts before the dwell; a continuously
moving mouse never mounts until it rests; idle found-swap replaces
same-frame without stacking; duration auto-hide needs a move + fresh
dwell to remount; drag-in-progress mounts immediately. 38/38 pass.
Register TS-85 and ISSUES item 2 corrected honestly: the "edge-fired
(no dwell)" conclusion is superseded by the user's retail evidence and
the m_dragElement branch read.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Batch A: world tooltips orphaned per hover transition (one-popup invariant,
live-verified 103 mounts/102 removes) + the UiTemplateListBox viewport
baseline-capture race clipping the Config tab. Batch B: cast-button +
character-panel attribute/vitals/skill tooltips (InqSkillFormula recovered
from unlabeled fragments; 34 skills live-verified). Batch C+closers: the
retail Map/House toolbar panel — panel id 16 at slot 0x1000018C, button
0x1000019A, the byte-decoded PlaceMarkerOnMap projection (span-normalized,
north-up), the verbatim 53-town table with hover tooltips, the Dereth
date/time line, coords readout, login-time HouseQuery + RuntimeHouseState
with retail's single houseless sentence. Opus round review F1-F15 fixed
(the marker formula was a BN FPU-elision misread — byte-re-derived;
three 'unrecoverable' strings recovered; AD-107 retired, AD-108/IA-23
filed). Final numeric live verification: ring at exactly the computed
pixel (226,125), Holtburg hover tooltip, House text, graceful logout.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two mechanisms, both live-verified (register row AD-108 updated to match):
1. RESOLUTION. The player/house icons (0x100001ED/0x100001EE) are authored
as nested dat children of m_pMap (0x100001EC), itself a Type-1 button
whose UiButton.ConsumesDatChildren swallows them at build. The old
ResolveSwallowedIcon re-imported them standalone via
ImportInfos(hostLayout, iconId) — which returns null on the live DAT:
FindDesc walks the raw top-level Elements table (one entry for
0x2100006E) and never reaches them. Their ElementInfos only materialize
inside the full panel-slot resolve (ImportInfos(0x2100006E, 0x1000018C))
that MountMapHousePanel already imports — the pageInfo Bind already
receives. The fix finds each icon's info under m_pMap's own resolved
info subtree and BUILDS it through the new Bindings.IconBuilder seam
(production: LayoutImporter.Build under the DAT lock — the build half
of RowTemplateResolver's shape). An icon the normal walk DID build is
preferred (FindDescendant first), so a future ConsumesDatChildren
policy change cannot double-build.
2. POSITION. Found by this fix's own F1 live verification: the resolved
ring rendered pinned to m_pMap's top-left. PlaceMarker owns marker
position outright (retail's gmMapUI::Update re-places every tick;
retail's UpdateForParentSizeChange runs only on real parent resize),
but acdream re-runs ApplyAnchor per frame and the icon's compatibility
anchor had captured the authored (0,0) rect while the panel was still
hidden, re-asserting it over PlaceMarker's writes every frame.
PrepareIcon now sets Anchors=None (clearing any imported LayoutPolicy),
the established runtime-positioned-element convention.
Live numeric gate (session character +Acdream, cell 0xF07E003F):
independent computation (gid_to_lcoord -> display (90.8E, 0.5S) ->
byte-decoded PlaceMarkerOnMap formula, 17x16 icon, marker area
(6,8)-(247,258)) predicts local pixel (226,125); the connected client's
UI-tree dump shows the icon at screen (1166,195) under m_pMap (940,70) =
local (226,125) — exact match in both panel-open dumps. Coordinate text
"0.5S,90.8E", Holtburg town-marker tooltip (real-mouse hover), and the
House tab's "You may buy another house immediately." sentence all
confirmed on screen; ACE-confirmed graceful logout.
New pin: MapHousePanelLiveDatMountTests ([InstalledDatFact]) reproduces
the production mount recipe against the installed DATs — the test that
would have caught this at Batch C: pins the cold-import null, the
panel-slot resolution of both icons with non-degenerate extents, AND
that PlaceMarker's writes survive the per-frame ApplyAnchor pass.
Gates: Release build green; App suite (live-DAT mode) 5479/3 skips
(baseline 5478 + the new pin); Runtime 1744/0; full solution green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Live verification against the connected client (part of the F1-F15
gate) found the Map tab's player/house icons never mount:
"[D.2b] Map tab: icon 0x100001ED did not resolve" / "...0x100001EE did
not resolve". AD-108 (filed earlier this session for F9) had described
the standalone re-import mechanism as working; it does not.
A throwaway diagnostic (not committed) confirmed the root cause:
LayoutImporter.ImportInfos(dats, hostLayoutId, elementId)'s FindDesc
walks the LayoutDesc's raw top-level Elements table (one entry) and
recurses through ElementDesc.Children with no tab-page/state resolution
— calling it directly with these icon ids returns null. Resolving the
panel's own slot first (what MountMapHousePanel actually does) and
searching THAT tree finds m_pMap with both icon children present, so
the icons are real, just unreachable via a cold standalone import.
This is pre-existing (predates this session, confirmed via git log)
and unrelated to any F1-F15 fix — it means F1's byte-decoded
PlaceMarkerOnMap formula could not be visually confirmed against the
running client this round; it remains verified only at the unit-test/
golden-pixel level. Filed a follow-up task for the fix.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
F11: logs when a Map tab town-marker's template resolves to something
other than a UiButton — that path previously silently skipped the
TooltipText write with no diagnostic, leaving a mounted-but-empty
tooltip popup indistinguishable from "no template configured".
F13: RetailSkillFormula.FormatFormula now reads Attribute1Multiplier/
Attribute2Multiplier/AdditiveBonus/Divisor through the SAME unsigned
reinterpretation TryCalculate already uses (this class's own doc
comment already stated the invariant; FormatFormula just didn't follow
it). A high-bit-set value would previously both mis-gate hasAttr1/
hasAttr2 and print a negative number, out of sync with what
TryCalculate actually computes with for the same formula. Added
regression tests, empirically verified to fail without the fix.
F14: documented the RefreshHouseMarker gap rather than guessing at the
byte-decode — Position::get_outside_cell_id @0x004527b0 is itself
BN-mangled (its `(eax_2 - eax_2) & objcell_id` return is the same
decompiler-obscures-a-real-conditional artifact class this round hit
elsewhere) and depends on LandDefs::adjust_to_outside, a genuinely
larger port than this round's other findings. HousePosition is wired
() => null in production today (ISSUES #413's remaining scope), so
this method is currently unreachable; left a TODO citing the retail
call chain for whenever that lands.
F15: fixed RefreshCoordinatesAndPlayerMarker's gate to AND-on-both-
present, matching gmMapUI::Update @0x004a2078's exact
`if (m_pCoordinateText != 0 && m_pPlayerLocationIcon != 0)` condition.
The prior `_coordinateText is null && _playerIcon is null` check only
skipped when BOTH were absent (proceeding whenever EITHER was
present), letting coordinate text and the player marker update
independently instead of as the single gated unit retail treats them
as. Added a regression test (player-icon template resolution failure
must also skip the coordinate-text write), empirically verified to
fail without the fix.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
F9: filed register row AD-108 for MapPageController.ResolveSwallowedIcon
— the standalone re-import of the Map tab's player/house icons, which
m_pMap's own Type-1 UiButton authoring swallows as dat children
(UiButton.ConsumesDatChildren). This adaptation was implemented but
never had a register row.
F10: extracted the popup-locator pair (0x10000395/0x21000041),
previously duplicated as three separately-cited private constants
across UiItemSlot.cs, RetailTooltipPresenter.cs, and
MapPageController.cs, into ONE public pair on RetailTooltipPresenter
(SharedPopupSkinRootElementId/SharedPopupSkinLayoutDid) with a single
canonical citation. The other two sites now reference it instead of
carrying their own copy.
F12: fixed TS-85's SetTooltip-site arithmetic. The register (and a
mirrored ISSUES.md log entry) claimed "15 known sites, all accounted
for" — recounting the row's own enumerated list finds 17 distinct
sites (the tally had dropped gmPaperDollUI::UpdateItemSlotTooltip
@0x004A52EF and undercounted by one more), of which 16 are ported and
one — UIElement_Text::RecalculateTruncation @0x00466F80, the headline
highest-volume site sub-mechanism (1) itself named as deliberately
deferred — was never actually closed. The "all 15 accounted for"
close was wrong twice over: wrong count, and a site the row's own text
already scoped as open.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
gmHouseUI::DisplayPurchaseTimeText @0x004a3110's not-yet-expired
branch was wrongly marked "unrecoverable from this decomp dump" — a
direct capstone disassembly resolves all three concatenated pieces:
prefix "You may buy another landscape house at " @0x7ab790 (pushed
@0x004a3265), the strftime "%c" format literal @0x7ab7ec (pushed
@0x004a321d) applied to localtime(timestamp + 0x278d00) — the expiry
moment, 30 days after the purchase timestamp — and suffix ". This
restriction does not apply to apartments." @0x7ab7b8 (pushed
@0x004a3235).
Ported in RuntimeHouseState.Recompute, substituting .NET's
culture-default DateTime.ToString() for the CRT's strftime("%c", ...)
(different formatting engine, same "process locale, full date+time"
intent) — filed as register row IA-23 (an approximation, not a gap).
TimeProvider.LocalTimeZone (overridable, defaulting to
TimeZoneInfo.Local in production) keeps the conversion deterministically
testable while matching retail's own localtime() call.
Updated RuntimeHouseStateTests: the not-expired case now asserts the
composed prefix/suffix structure and the exact expiry instant (pinned
via a UTC-fixed test TimeProvider), replacing the old "renders nothing"
assertion. Un-claimed "unrecoverable" in ISSUES #413 item 2.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
F5: moved the unconditional RemovePopup() call into
RetailTooltipPresenter.TryBuildAndMountPopup itself so the single-
popup invariant (retail's own single m_pTooltipElement slot) is
enforced structurally rather than relying on every caller to have
already cleared a stale popup. Closes a real hole: UpdateWorldHoverTooltip's
own clear is gated on _worldTooltipShowing (only true when the WORLD
path itself mounted the current popup), and its "a UI popup cannot be
showing here" comment assumed the host's hover query is null whenever
that branch runs — an assumption that breaks the instant a modal opens
over a stationary cursor. UiRoot.Modal claims EXCLUSIVE hit-testing, so
Pick(MouseX, MouseY) can return null even though a UI-dwell tooltip is
still mounted underneath; UpdateWorldHoverTooltip would then mount a
second popup on top without ever clearing the first.
F6: fixed WorldHover_ThenUiDwellTooltip_ReplacesRatherThanStacks to
actually exercise the transition with a follow-up presenter.Tick()
(the old test only proved OnTooltipShow's own clear worked, never
checked the world-side bookkeeping after). Added
UiDwellTooltip_ThenModalStealsHitTesting_WorldHoverReplacesRatherThanStacks
for F5's own case, using UiRoot.Modal to reproduce the exclusive-hit-
testing hole precisely — empirically verified this new test fails
(2 popups instead of 1) with the structural RemovePopup() reverted,
confirming it is a real regression test.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
F3: TS-85 had claimed the plain-spell branch's three SetTooltip format
strings were "genuine gmNoticeHandler vtable SLOTS" and unrecoverable
from the decomp dump. That was itself the artifact — Binary Ninja's
pseudo-C rendering of PStringBase::sprintf's second argument as
"&gmSpellcastingUI::`vftable'.RecvNotice_XXX" was a spurious symbol
match, not the true operand. A direct capstone disassembly of the raw
bytes at gmSpellcastingUI::UpdateCastButtonTooltip @0x004c6a30's four
call sites (0x4c6e48/0x4c6ea4/0x4c6f18/0x4c6f5d) resolves the actual
pushed literals: "CAST %hs" @0x7b63a4 (untargeted/self-cast, and
targeted+compatible with " on %s" @0x7b6464 appended), "You must
select an appropriate target for %hs" @0x7b6348 (incompatible target),
"You must select a target for %hs" @0x7b63b8 (no target). %hs is the
spell's own name throughout.
Added RuntimeSpellCastState.EvaluateCastGate (SpellCastGate: NoTarget-
Needed/TargetCompatible/TargetIncompatible/NoTargetSelected/Unknown),
refactoring IsTargetReady to use it, and wired
SpellcastingUiController.ComputeSpellCastState to the four-state
tooltip text, replacing the bare-spell-name fallback.
F4: the endowment branch's "USE the %s" (and both select-target
strings) vararg is NOT the bare item name — retail composes
"%s (%hs)" @0x7b64d8 (item name, spell name) once at @0x004c6bb6-ef
and reuses it for all three format strings, byte-confirmed by all
three sprintf call sites (0x4c6c7f/0x4c6ca4/0x4c6d46) reading the
identical stack slot. Added ComposeEndowmentName and wired it in place
of the bare item name.
F7: added test coverage for the two genuinely NEW disabled states
(needs-target, needs-appropriate-target) neither branch had any
coverage for before, plus the enabled untargeted/targeted-compatible
states and both endowment-branch composed-name cases.
Corrected the register's TS-85 row (the "cannot be recovered" claim
and the endowment operand claim) with the byte-decoded findings.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CM_House::Event_QueryHouse @0x006aaa00 (opcode 0x21e) tail-calls
unconditionally from the end of CPlayerSystem::InitializePlayer
@0x00563570 — the same once-per-session function
AttemptSendLoginCompleteNotification lives in (both guarded by the
player_initialized flag), right after that notification. Retail never
sends it from gmHouseUI::PostInit or gmMapUI::PostInit on House-tab
activation.
Moved WorldSession.SendHouseQuery() to the direct (non-portal)
first-entry completion edges — the same places acdream already sends
the analogous "initial session bootstrap" LoginComplete:
- graphical: LiveSessionRuntimeFactory's RuntimeFirstEntryDriveController
localPlayerCompleted callback
- headless: HeadlessSessionHost's equivalent callback
- headless content-less direct host: RuntimeLiveEntitySessionController.OnSpawned
Portal-space re-entries (LocalPlayerTeleportController's F751 path,
RuntimeLiveEntitySessionController.TryAdvancePortalCompletion) do NOT
resend it, matching retail's single-shot guard.
Removed the invented House-tab-open -> SendHouseQuery trigger
(InteractionRetainedUiComposition's HouseShown binding) and retired
register row AD-107, which had documented that adaptation.
Updated RuntimeLiveEntitySessionControllerTests' exact game-action
assertions for the content-less path, which now also captures the
HouseQuery send alongside LoginComplete.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The prior PlaceMarker() reading ("center at markerX0+x") was wrong.
Binary Ninja elides gmMapUI::PlaceMarkerOnMap @0x004a18b0's entire FPU
chain to bare, operand-less _ftol2() calls, so the pseudo-C
under-specifies the function. A capstone disassembly of the raw bytes
in the PDB-paired acclient.exe recovers the real formula: retail
projects the AC display coordinate (range ~-102.4..102.4) onto the
marker-area rect via a fixed-point-style transform, not a raw pixel
add:
X = m_x0 - w/2 - (int)((m_x1-m_x0+1) * (x*10+1024) * (-1/2048))
Y = m_y0 - h/2 - (int)((m_y1-m_y0+1) * (2047-(y*10+1024)) * (-1/2048))
Constants read directly from .rdata: 0x79bac8=10.0, 0x7aac78=1024.0,
0x7aac70=-1/2048, 0x7aac68=2047.0. The Y axis's FSUBR is retail's
north-up flip. w/h halve with truncating integer division (matching
retail's cdq;sub;sar idiom), not float division.
Extracted the pure math into MapPageController.ComputeMarkerPosition
so it's directly testable, and retargeted MapPageControllerTests to
GOLDEN PIXEL values computed independently from the formula (never
from the port's own output): the reviewer's canonical (0,0)->(122,128)
case, a far-west and far-north case, and a real town-table entry
(Arwic's landblock, cross-checked against RadarCoordinates). Applies
to the green ring, house pin, and all 53 static town hotspots, which
all resolve through the same PlaceMarker call.
Corrected the recon doc's "accepted as-is" note, which had mistaken
"the FPU argument-passing is BN-mangled" for a narrow issue instead of
the whole-formula elision it actually was.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Derived the mechanism from the decomp before writing code: neither
gmHouseUI::PostInit @0x004a2710 nor gmMapUI::PostInit @0x004a1c70 sends a
HouseQuery, and six of gmHouseUI's seven Display* builders early-return on
m_pHouseData == 0. The only text a houseless character's House tab shows is
gmHouseUI::DisplayPurchaseTimeText @0x004a3110's expired branch (it doesn't
gate on m_pHouseData) — the local player's PropertyInt.HousePurchaseTimestamp
plus HouseSystem::HasPurchaseWaitPeriodExpired renders exactly "You may buy
another house immediately." for a fresh character. Exhaustive search of the
2013 EoR decomp, ACE, and the live DAT found zero support for a second
"You do not currently own a house." line the task brief described — this
commit ports what the decomp actually shows.
Ships:
- RuntimeHouseState: a minimal (no disposal, no construction-transaction
Fault() point) Runtime owner per ISSUES #413's own sizing note, wired
through GameEventWiring's existing HouseData/HouseStatus delegate holes,
LiveSessionEventRouter, and GameRuntime.HouseOwner. Participates in
RuntimeGenerationReset (new House stage) since a fresh login must not
show a stale character's house state.
- HousePageController.Bindings.Lines/OnShown wired to real data; OnShown
fires WorldSession.SendHouseQuery() on tab-open (AD-107: an acdream
trigger, not a ported retail call site — filed in the divergence
register).
- Fixed a real bug found along the way: HousePageController.Bind never
wired UiTemplateListBox.TemplateResolver, so no row could ever render
regardless of Lines content. Now reuses the Map tab's generic hotspot
resolver.
Live-verified against a real local ACE server and the +Acdream character
(--session-config auto-select + a UI automation script): screenshot and
structural UI-tree dump both confirm the House tab renders exactly "You may
buy another house immediately." Graceful logout confirmed both launches.
ISSUES #413 narrowed to its one remaining piece: the six owned-house-only
Display* builders (DisplayBuyPayment/RentPayment/BuyTime/RentTimes/
Location/WarningText), unexercisable without a test character that owns a
house.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The row committed alongside the panel shell (8799acd2) described the
Batch C map-marker tooltip as using AuthoredTooltipText/Enabled — that
was the pre-live-verification code. Slice 5 found it never rendered
live and the actual fix (commit e5629d71) uses UiButton.TooltipText
(retail's runtime m_TTText/SetTooltip mechanism) plus a hardcoded
popup-skin locator matching UiItemSlot's precedent. Updates the row
to describe the shipped mechanism instead of the abandoned one.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Live verification (slice 5) found town-marker tooltips never appeared:
RetailTooltipPresenter.OnTooltipShow gates unconditionally on
AuthoredTooltipRootElementId == 0 -> return, with no fallback, but the
markers only set AuthoredTooltipText/Enabled (the DAT-authored P0x49
path). gmMapUI::AddMapNote's UIElement::SetTooltip call is retail's
RUNTIME m_TTText/SetTooltip mechanism, not the authored path — the
correct seam is UiButton.TooltipText (backing GetTooltipText()'s
override), which ResolveTooltipText consults before authored text.
The popup-skin locator (AuthoredTooltipRootElementId/LayoutDid) is
still required even on the runtime-text path with no built-in
fallback, so markers now hardcode the same shared popup skin
UiItemSlot already uses (0x10000395/0x21000041) — matching that
established precedent exactly.
Verified live: hovering a town marker (Aerlinthe Island) now renders
its tooltip correctly. 21/21 Map/House controller tests still pass.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The class doc referenced RuntimeHouseState.PurchaseAvailabilityText as
already wired this session; it isn't (deferred to #413, the
RuntimeHouseState owner integration). Corrected to accurately describe
what shipped (mount + wire parsing groundwork) vs what's still open.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
gmMapUI::AddMapNote's 53 town-hotspot tooltips are now ported
(MapPageController.BuildTownMarkers), closing the last remaining
SetTooltip call site TS-85's sub-mechanism (1) enumeration tracked.
Sub-mechanism (2) (the P0x3D wrap-width override) remains open and
unrelated to this batch.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Precise scope note for the remainder of the House tab wire, per the task's
pre-authorized fallback: RuntimeHouseState owner integration,
DisplayPurchaseTimeText's port (the one builder simple enough to have
landed this session but deferred for time), and the other six Display*
line builders (only exercisable once a house is actually owned).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds the outbound HouseQuery action (0x021E, ClientCommandRequests.
BuildHouseQuery / WorldSession.SendHouseQuery — ACE GameActionHouseQuery.
Handle reads no payload) and inbound parsers for all four House wire
opcodes GameEventType already defined (0x0225-0x0228, gmHouseUI::PostInit's
registered notice handlers): GameEvents.ParseHouseData (BuyTime/RentTime/
Type/MaintenanceFree/Buy list/Rent list/Position — the Position field
reuses CreateObject.ServerPosition's existing 32-byte Cell+Pos.XYZ+
Rotation.WXYZ shape rather than a new type), ParseHouseStatus (WeenieError
u32), ParseUpdateRentTime, ParseUpdateRentPayment. Wire shapes verified
against ACE's HouseDataExtensions/HousePaymentExtensions (references/ACE/
Source/ACE.Server/Network/Structure/HouseData.cs, HousePayment.cs) — noted
that ACE's own UpdateRentTime/UpdateRentPayment writers are stubs (always
0u / always an empty list), captured as such rather than assumed live.
GameEventWiring.WireAll gets four new optional delegate holes
(onHouseData/onHouseStatus/onHouseUpdateRentTime/onHouseUpdateRentPayment)
following the exact trade-family precedent — registered only when non-null,
every existing caller compiles unchanged.
This is the "enum/parser groundwork" half of Slice 4's pre-authorized
fallback. NOT included (filed as an ISSUES entry): a RuntimeHouseState
GameRuntime owner (construction-transaction ceremony, fault-injection
points, disposal/convergence tracking — the same weight as
RuntimeTradeState's integration, judged disproportionate for tonight
alongside the completed Map tab), HousePageController's real Lines/
OnShown wiring, the DisplayPurchaseTimeText port, and the six other
Display* line builders. The House tab currently mounts with genuinely
empty content, matching retail's own PostInit (verified via
MapHousePanelSlotProbeTests' live-DAT probe, not assumed).
9 new HouseEventsTests (parser round-trips + truncation), 1 new
GameEventWiringTests case (all four opcodes reach their callbacks).
Core.Net.Tests: 1004/1004 passed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Mounts host 0x2100006E slot 0x1000018C (RetailPanelCatalog.MapHouse = 16)
as a two-tab UiTabPanel (Map default, House second) through the OP3/FA3
recipe (LayoutImporter.Build -> Bind -> ActivateTabBehavior). Toolbar
button 0x1000019A un-ghosts (added to both RetailPanelCatalog.Mounted and
.Toolbar). Combined into one commit because MapHousePanelController.Bind
depends on both MapPageController and HousePageController existing —
splitting them would mean landing dead code first.
Map tab (gmMapUI, MapPageController):
- Calendar formatter matching gmMapUI::Update's "Date: %s\nTime: %s"
shape, reusing WorldTimeService.CurrentCalendar (new
Func<DerethDateTime.Calendar> dependency threaded through
InteractionRetainedUiDependencies/GameWindow — a stable long-lived
service, not routed through the deferred-binding machinery Radar's
per-session state needs). MonthName enum values already match retail
display text; HourName's "AndHalf" suffix is rewritten to "-and-Half".
- Coordinate math + marker placement reuse RadarCoordinates/
LandDefs.GidToLcoord verbatim (both already byte-exact ports of
CPlayerSystem::InqPlayerCoords/LandDefs::gid_to_lcoord) — no re-port.
PlaceMarkerOnMap's centering math (m_x0 + x - w/2) ported from
gmMapUI::PlaceMarkerOnMap @0x004a18b0. Indoor gating clears the
coordinate text and hides the player marker, matching
gmMapUI::Update's else branch.
- 53-town s_rgLocations table ported verbatim into MapLocations.cs.
Markers built once at bind time via the panel's own RowTemplateResolver
against m_pMap's authored hotspot-template attrs (0x47/0x48), with
literal-string tooltips through AuthoredTooltipText/Enabled
(RetailTooltipPresenter) — closes divergence-register row TS-85's last
item, gmMapUI::AddMapNote @0x004A1C51.
- Structural finding: m_pMap (0x100001EC) is itself authored as a Type-1
BUTTON (the GM click-to-teleport hook at
gmMapUI::ListenToElementMessage), and the player/house icons
(0x100001ED/EE) are its own NESTED children, not siblings —
UiButton.ConsumesDatChildren swallows them from the normally-built
tree. Both are re-resolved standalone through the same template
resolver the town hotspots use and reattached under m_pMap.
House tab (gmHouseUI, HousePageController): mounts the ListBox
(0x100001E6) with its authored row template, wired to an empty Lines()
source by default — genuinely empty until Slice 4's wire lands, matching
retail's own PostInit (no Update call, no static content).
21 new tests (7 MapHousePanelControllerTests, 14 MapPageControllerTests):
tab table pairing, close button, town-hotspot count/tooltips, calendar
formatter golden values (Frostfell 27/119 P.Y., every HourName incl.
AndHalf), player/house marker placement and indoor-gating reproduced
against the real fixture via already-tested RadarCoordinates (no
re-derivation). Fixture map_house_2100006E_1000018C.json captured via
the shared RetailLayoutFixtureGenerator (other 34 fixtures deliberately
NOT regenerated — out of scope for this batch, would touch unrelated
panels' schema drift).
Full solution builds clean; App suite 5391/0 failed/71 skipped (non-live;
one earlier flaky streaming failure unrelated to this change, confirmed
pre-existing on the branch before these commits).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Live-DAT probe confirming the desk-verified facts before implementation:
host 0x2100006E slot 0x1000018C carries panelId 16 (gmMapUI::PostInit
signature children 0x100001EB-EF; gmHouseUI's ListBox 0x100001E6),
tabTableCount=2 with Map (button 0x100001F3 -> page 0x100001F6) as the
authored default and House (0x100001F4 -> 0x100001F7) second, close button
0x100001F5. Toolbar button 0x1000019A carries the matching panelId 16 —
the Map/House entry among the toolbar's three ghosted buttons. m_pMap's
own marker-area rect is (6,8)-(247,258); its hotspot template attrs
(0x47/0x48) resolve to element 0x100001F0 in LayoutDesc 0x21000026, a
10x10 Type-1 button with 3 states. The House ListBox authors exactly one
row template (LayoutDesc 0x21000025 element 0x100001E7, a bare
UIElement_Text row, no scrollbar) and zero static child rows — the box is
genuinely empty until the first server notice, refuting the recon's
"authored default content" hypothesis for the no-house case.
Kept as a permanent env-gated pin (ACDREAM_PROBE_LIVE_MOUNT=1), matching
FaPanelSlotProbeTests' precedent.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Saves the overnight-round recon (embedded findings + this session's desk
verification) for auditability before implementation starts. Corrects two
handoff claims: the panel id is 16 (already resolved by the existing FA
campaign's full 16-slot gmPanelUI::SetupChildren dump, not a guess from
{1,2,6,14}), and GameEventType already defines all four House opcodes
(0x0225-0x0228) — what's missing is routing, not the enum. Identifies that
LandDefs.GidToLcoord/LcoordToGid (src/AcDream.Core/Physics/LandDefs.cs) is
an existing tested port of LandDefs::gid_to_lcoord, reusable for both the
Map tab's coordinate math and the House location display — no re-port
needed. Cites the toolbar button (0x1000019A, panel id 16), the 53-entry
s_rgLocations marker table verbatim, the ServerPosition wire struct reuse
for HouseData.Position, and the AuthoredTooltipText/RetailTooltipPresenter
seam that will close register row TS-85's last item (gmMapUI::AddMapNote).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
TS-85 remainder batch (hover/UI overnight round, batch B). Audit found
three of the four listed spellcasting SetTooltip sites (endowment icon,
favorite, submenu) were already correct via UiCatalogSlot's pre-existing
Label-driven GetTooltipText; only the cast button (UiButton, no tooltip
wiring at all) was a real gap. Ports the verified literal states
("Select a spell to cast" / "You have no spells ready to cast" / the
full endowment-item USE-the-%s branch) plus a documented, narrower
fallback (spell name only) for the one sub-branch whose exact wording
sits behind a genuine gmNoticeHandler vtable-slot collision in the
pseudo-C dump rather than the unlabeled-string-pool class the rest of
this batch recovered.
Character panel: new UiClickablePanel.TooltipText seam (same pattern as
UiButton.TooltipText) carries the six hardcoded attribute descriptions
and three pair-shared vitals descriptions (byte-decoded from the retail
string pool) plus skill tooltips composed from the already-DAT-parsed
SkillBase.Description/.Formula — no hand-transcription needed for the
~30+ skill strings. The formula-to-text algorithm itself
(SkillSystem::InqSkillFormula) was recovered by byte-decoding six short
fragments Binary Ninja left completely unlabeled between two
gmSpellcastingUI vtable declarations.
Live-verified against the local ACE server: 34 real skills' composed
tooltips and both reachable cast-button states captured via a temporary
probe (stripped before this commit). Full solution suite green
(14,647 tests, 0 failures) both before and after the probe strip.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Config tab's footer sat mid-panel with further rows drawing below the
window's bottom edge. Live-DAT measured: the mounted tab-host root is
authored 300x362 (retail's real default window size), but the Config page
slot underneath keeps its own larger design geometry (298x575 against a
300x600 canvas) until retail's real four-edge UiLayoutPolicy
(UIElement::UpdateForParentSizeChange @0x00462640) shrinks it on the first
ApplyAnchor pass -- verified stable, this part already worked.
The actual bug: UiTemplateListBox.Viewport (the UiScrollablePanel that
hosts + clips every row) is a programmatic C# element seeded at Bind time,
BEFORE the tree's first draw frame -- before the ListBox has ever shrunk.
Its legacy anchor baseline is captured lazily on its own first ApplyAnchor
call, which lands AFTER the ListBox has already shrunk earlier in that same
frame (parent-before-child draw order). That capture measures a negative
bottom margin the stretch math preserves forever: the viewport stayed
locked at its original 560px design height, clipping rows to a bound
retail never actually gave the window on screen.
Fix: force the viewport's anchor capture to happen immediately after
seeding it, while its Width/Height still exactly equal a zero-margin
baseline against the CURRENT (pre-shrink) parent, instead of lazily on the
first draw frame against an already-shrunk parent. This is #372's sequel --
#372 fixed the 0x0 collapse case; this is the "ListBox itself later
shrinks" case #372's own fixture never exercised.
Three new tests (UiTemplateListBoxViewportTests using the live-DAT-measured
298x575/276x560 numbers, plus two ConfigOptionsPageControllerTests against
the real production Bind path and the committed host fixture) all fail
pre-fix, confirmed by temporarily reverting the change. Scoped to
UiTemplateListBox's own viewport; UiScrollablePanel/ApplyAnchor/
ComputeAnchoredRect are untouched, so chat's transcript scrolling and every
other UiScrollablePanel/UiItemList consumer are unaffected.
fix#412
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
RetailTooltipPresenter.UpdateWorldHoverTooltip only called RemovePopup()
on the found-object-LOST edge (found == 0u). An A->B found-object CHANGE
(walking past a run of NPCs/doors/lifestones with no intervening "nothing
found" frame) skipped straight to TryBuildAndMountPopup with the previous
popup still mounted as a child of _host -- only the _popupRoot reference
got overwritten, so every earlier popup was orphaned in the tree and never
removed. Matches the user's screenshot of 15+ stacked name boxes.
Fix: clear any showing world popup on ANY found-object edge -- change or
loss -- before evaluating whether to mount a new one, mirroring
OnTooltipShow's own unconditional RemovePopup() at its top.
Live-verified against local ACE (testaccount/+Acdream, session-config
launch): a temporary probe logged 103 mount/102 remove events across many
direct object-to-object transitions (Silver Tusker, Armored Tusker,
+Acdream); hostChildren never exceeded baseline+1 and popupSkinChildren
never exceeded 1 -- confirmed at most one tooltip ever exists. Probe
stripped before landing; two new fixture regressions
(WorldHover_FoundObjectChangesDirectly_ReplacesThePopupWithoutStacking,
WorldHover_ThenUiDwellTooltip_ReplacesRatherThanStacks) both fail pre-fix.
fix#409 (follow-on)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Records the round that shipped item-cell tooltips, the world-object
hover tooltip, and the #411 cursor-swap fix: #409's write-up gains a
"hover-feedback completion round" section covering all three items
with live-verification notes; #411 is closed with the corrected
decomp reading; register row TS-85 is narrowed to reflect the two
newly-ported SetTooltip call sites (UIElement_UIItem::UpdateTooltip,
UIElement_SmartBoxWrapper::RecvNotice_SmartBoxObjectFound) and the
still-open ones (spellcasting endowment/cast-button/favorite/submenu,
map notes, character-panel attribute/skill info).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Corrects an incomplete reading from #411's original investigation.
UIElement_SmartBoxWrapper::FindObject @0x004E5430 calls
SmartBox::set_found_object(itemID, 0xFFFFFFFF) whenever the hovered
UI element (m_pElementLastOver) casts to UIElement_UIItem
(class 0x10000032) — UNCONDITIONALLY, not gated on target mode, and
returns WITHOUT running the 3D raycast. ClientUISystem::
UpdateCursorState @0x00564630 computes its "found" flag ONCE at the
top of the function (ebx = SmartBox::get_found_object_id() != 0,
@0x00564642) and every later branch (default/melee-missile/magic/
use/examine/use-target/busy) reads that SAME flag — so hovering an
occupied item cell shows the cursor's "...Found" variant in EVERY
mode, not only during an active UseTarget selection.
CursorFeedbackController.Update(UiRoot) already had the item-hover
special case wired from an earlier round but incorrectly gated it to
TargetMode.UseTarget only; that one-line gate is removed.
ResolveGlobalKind needed no changes at all — it already read the
snapshot's HoverTargetGuid unconditionally across every mode.
Two new tests pin the widened behavior in ordinary peace mode and in
combat mode. Live-DAT-independent (pure decomp + unit fixture).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
NOT the UI-element dwell-timer path. Retail's mechanism is
UIElement_SmartBoxWrapper::RecvNotice_SmartBoxObjectFound @0x004E5AD0,
fed every frame by FindObject @0x004E5430/Global_Loop @0x004E5620
using the current mouse position regardless of input focus. It fires
IMMEDIATELY (no dwell wait) on the found-object id CHANGING, gated by
the PlayerModule::ShowTooltips character option (already modeled in
CharacterOptionTable, default true), with text
ACCWeenieObject::GetObjectName(id, NAME_APPROPRIATE, 0) — the SAME
name call as item tooltips, but WITHOUT the item-cell's separate
stack-count prefix (a ground pile of arrows shows "Arrows", not
"20 Arrows" — a real, decomp-confirmed asymmetry).
Ported as RetailTooltipPresenter.UpdateWorldHoverTooltip, driven by
the SAME world-hover pick CursorFeedbackController's own found-cursor
already uses (WorldSelectionQuery.PickAtCursor, includeSelf: true —
own player is included on that precedent) and the SAME
ClientObjectTable-backed name resolver SocialAllegiancePageController's
ResolveWorldObjectName already established as this codebase's
pattern. New WorldTooltipRuntimeBindings threads it through
RetailUiRuntimeBindings; wired at InteractionRetainedUiComposition
alongside the existing cursorFeedback construction.
Queried only when no UI element is hovered — a narrowing from
retail's literal "raycast even under non-item UI chrome" (FindObject's
m_pElementLastOver check), called out in the class's own doc note as
a scoped interpretation rather than a byte-exact port.
The exact popup skin is an inference, not a measured value: an
exhaustive live-DAT sweep found UIElement_SmartBoxWrapper (class
0x10000030) has NO authored ElementDesc anywhere installed — unlike
every other tooltip trigger, it is evidently constructed directly by
gmGamePlayUI's own mode setup, not from a walkable LayoutDesc. This
port reuses the same P0x47=0x10000395/P0x48=0x21000041 pair every
other game-code SetTooltip caller in this family resolves to — the
best-evidenced choice, called out in register row TS-85 rather than
silently assumed exact.
Live-verified against a connected ACE session (session-config launch,
+Acdream): hovering a "Silver Tusker" near spawn mounted the correct
popup text and simultaneously flipped the cursor to its DefaultFound
variant, confirming the shared found-object pipeline drives both.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Retail UIElement_UIItem::UpdateTooltip @0x004E1CB0 caches the item's
NAME_APPROPRIATE display name (stack-count-prefixed "%d %s" when
StackSize > 1) as m_TTText every UIItem_Update refresh; the generic
UIElementManager::CheckTooltip dwell timer is what actually shows it
on hover — no special-cased trigger of its own.
UiItemSlot cells are built programmatically (never through
LayoutImporter.Build), so #409's original round left this deferred:
the class carried neither the popup locator (P0x47/P0x48) nor a name
source. A live-DAT sweep of the shared UIItem cell-template catalog
(ItemListCellTemplate.CatalogLayoutId, 0x21000037) found all 47
UIItem-type (class 0x10000032) prototypes — inventory's cell, every
toolbar slot, every paperdoll/armor slot skin — resolve the IDENTICAL
popup locator (P0x47=0x10000395/P0x48=0x21000041) through catalog
inheritance, with no literal text authored on any of them. UiItemSlot
now hardcodes that pair and exposes GetTooltipText() via a new
TooltipTextResolve delegate, wired at every physical-item
construction site: InventoryController (main-pack cell + grid cells),
ExternalContainerController, PaperdollController (closes the
separate gmPaperDollUI::UpdateItemSlotTooltip @0x004A52EF gap too —
same cell class, same fix), VendorUiController (shop/buying/selling
lists), SecureTradeUiController, ToolbarController.
Text is the new ClientObject.GetTooltipDisplayName(): GetAppropriateName()
prefixed with the stack count via "{count} {name}" when StackSize > 1,
matching UpdateTooltip's exact NAME_APPROPRIATE + "%d %s" sprintf.
UiCatalogSlot (spell/component catalog cells, a different UiItemSlot
subclass) is unaffected — it already overrides GetTooltipText() with
its own Label.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Retail reads the runtime m_TTText FIRST (StartTooltipAtMouse @0x00460DA3)
with authored P0x49 as fallback; the presenter read only authored text,
so every runtime-written tooltip (Options rows, checkbox bitfields, both
social pages — writers acdream already had) never showed. Fixed with
retail's resolution order + the P0x48 own-layout fallback; live-verified
on a connected client. The authored-243 population measured as entirely
chargen-resident. Deferred honestly: inventory item-name tooltips
(UIElement_UIItem::UpdateTooltip — UiItemSlot lacks the plumbing) and
#411 (hover cursor/rollover feedback, full mechanism mapped).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
User gate on 1.0.3-tt.a: tooltips appeared NOWHERE in-world except one on
the paperdoll. Root-caused, fixed, and live-verified against a connected
client the same day. Two findings, both measured; neither is a broken
hover/hit-test.
1. DOMINANT ROOT CAUSE — RetailTooltipPresenter.OnTooltipShow gated on
widget.AuthoredTooltipText (P0x49) alone. Retail's
UIElement::StartTooltipAtMouse @0x00460D70 takes the RUNTIME m_TTText
first (@0x00460DA3 IsValid -> @0x00460DAA verbatim) and only falls back
to InqProperty(0x49) at @0x00460DDF. acdream ALREADY had the runtime
layer — UiElement.GetTooltipText(), written by the Options/Chat/Config
page controllers, KeyboardConfigController, the social pages and
UiCheckboxBitfield64 — but nothing read it.
Live-DAT measured: the Options toggle-row checkbox (0x2100002B template
root 0x10000218, leaf 0x10000219) authors P0x47=0x10000397
P0x48=0x21000041 P0x4B=true and an EMPTY P0x49 — the popup locator and
the on-bit are authored; only the text arrives at runtime, exactly as
UIOption_CheckboxBitfield64::CreateChildren @0x00485E65 stamps its
siTooltip array. Re-measured client-wide: ALL 187 no-literal-text
tooltip elements author both locator ids, i.e. the whole set is
runtime-text targets.
Fixed by ResolveTooltipText (retail's order), plus:
- the P0x4B gate now applies only to the AUTHORED-text path, because
retail's eight game-code SetTooltip sites set the on-bit themselves
(__bitfield164 |= 0x20 at @0x004E1D5E/@0x004A52F4/@0x004C63AC/
@0x004C67ED/@0x004C7000/@0x004C7218/@0x004D9617/@0x00467076);
- the P0x48-absent fallback to the element's own LayoutDesc
(@0x00460E7E, this->m_layout->m_DID) is ported via the new
UiElement.SourceLayoutDid, threaded from LayoutImporter.Build's new
sourceLayoutDid parameter and passed by Import + the four template
resolvers.
2. THE "243 SHOWABLE" NUMBER WAS NEVER AN IN-WORLD NUMBER. Grouped
re-sweep: all 243 sit in CHARACTER-CREATION layouts. The inventory
window (0x21000023) and paperdoll (0x21000024) author exactly two
between them — 0x100001D6 "Drag clothing and armor here to wear them"
(the doll drag mask) and 0x100005BE (the Slots button). The first IS
the user's single working tooltip, so the paperdoll was never a
differential against a broken mechanism. Reachability was measured and
is fine: 238/243 build as real non-ClickThrough hover targets.
LIVE VERIFICATION (connected testaccount/+Acdream, Release,
ACDREAM_RETAIL_UI=1): Options -> Character -> "Vivid Targeting Indicator"
now shows its full ID_PlayerOption_*_Help sentence; a temporary hover probe
confirmed the hover target is element 0x10000219 with runtime=True. The
paperdoll tooltip still shows. An inventory ITEM still shows nothing —
that is UIElement_UIItem::UpdateTooltip @0x004E1CB0 (retail shows the item
name, "%d %s"-prefixed when the stack is > 1), which stays deferred:
UiItemSlot is constructed programmatically at 6+ sites and carries neither
the P0x47 locator nor a name source, so it is its own slice.
Bookkeeping: register TS-85 narrowed (m_TTText READ side now ported; the
row now enumerates all 15 SetTooltip call sites split into ported vs
no-acdream-analog). #409's gate note rewritten to lead with the in-world
surfaces — the old note listed only chargen, which is why it could not
have caught this. Filed #411 for the hover-cursor scope addition: an
exhaustive raw scan of every ElementDesc found only 101 authored
MediaDescCursor entries, all on Dragbar/Resizebar with the 5 DIDs
RetailCursorCatalog already hardcodes, so retail has NO per-element cursor
for inventory items; the likely mechanism is the rollover STATE
(UIElement::MouseOverTop @0x004615D0) that UiItemSlot lacks entirely.
Gates: Release build 0 errors; App suite (live-DAT env) 5424/5421 passed/3
skips (was 5416/5413/3, +8 new tests); Runtime 1735/0; full solution (no
env) 14,631/14,561 passed/70 skipped/0 failed (was 14,623/14,554/69).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Retail's five-part tooltip mechanism ported end to end: authored trigger
properties (P0x47-0x4B, P0x50) through the importer, the RetailTooltipPresenter
instantiating the authored 0x21000041 popup with retail's auto-resize/clamps,
the +32px cursor offset with display clamping, mouse-idle dwell (0.25s
default, per-element override), 10s auto-hide, retail's exact dismissal set,
capture suppression, and the Misc TooltipEnable/Delay client-local
preferences. 243 authored-text elements light up client-wide. Opus dual-lens
review + F1-F11 fix round complete; TS-85 records the honestly-deferred
m_TTText/SetTooltip family headed by the P0xD0 truncated-text auto-tooltip;
AD-106 records the z-order adaptation. Pending: the user's visual gate.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Opus review of a377b9bf returned architectural PASS-with-findings /
retail-fidelity FAIL with F1-F12 (F12 info-only). All eleven fixed,
each re-derived against docs/research/named-retail/acclient_2013_pseudo_c.txt:
- F1 PositionAtMouse: retail offsets BOTH axes +32px before the clamp
(StartTooltip @0x00459700, @0x00459739/@0x00459747) — was landing
flush at the cursor.
- F2 UiRoot: the dwell timer now anchors to mouse-IDLE like retail's
m_lastMouseMoveTime (MouseMoveHandler @0x0045E710), resetting on
every move within the same widget while !_tooltipFired, not just on
hover-enter.
- F3 register TS-85 rewritten: the "dynamic InqProperty(0x49) override"
framing was false — UIElement::InqProperty @0x004638D0's base impl
reads the same authored bags this port already reads. The real
second text source (m_TTText/SetTooltip, headed by the P0xD0
truncated-text auto-tooltip @0x00466F80) needs a per-line-position
truncation model UiText doesn't have — sized disproportionate for
this round and left honestly deferred rather than stubbed.
- F4 OnTooltipShow: null LayoutPolicy + Anchors=None on the popup root
and text child before resizing, mirroring RetailMessageDialogView's
sibling shape.
- F5 OnTooltipShow: return without mounting when the P0x4A text child
doesn't resolve to a UiText (retail's DynamicCast gate,
StartTooltip @0x0045DE90 @0x0045df65/@0x0045df6f) — was mounting an
empty 30x30 bevel artifact.
- F6 UiRoot.Tick: the dwell-arm branch now requires Captured is null
(CheckTooltip @0x0045B6E0 @0x0045b715) — a widget hovered before a
drag/resize/capture began must not pop mid-gesture.
- F7 UiRoot.ReleaseCapture: no longer resets _tooltipFired
(ReleaseMouseCapture @0x0045D2B0 touches only the idle timestamp) —
a mouse-up while a tooltip is shown no longer tears it down and
silently re-fires it 250ms later.
- F8 ApplyTooltipText: applies ResizeTo's own max/min width/height
clamps (P0x3C/0x3D/0x3E/0x3F, @0x00463C30) before assigning the
grown size; zeroes text.Padding to keep the measured size margin-
comparable. New ElementInfo/UiElement plumbing for the four
properties, same shape as the existing tooltip fields.
- F9 doc precision: sweep counts corrected 434->430 / 191->187 (live-
DAT re-measured), the "243 showable" claim now measured exactly
(not assumed) via a new Showable column in the sweep test, and the
MiscSettings citation split into its two real mechanisms
(RegisterPreference in Init vs. AttachPreference/SetPreferenceRange
elsewhere).
- F10 register AD-106: the topmost guarantee is versus dialogs/screens
only (the overlay popup layer and drag ghost still paint above
regardless), and the per-tick BringToFront ratchet has four rungs,
not three.
- F11 RetailUiRuntime.ResetSessionDialogs: now also calls the new
UiRoot.ResetTooltipTracking() so a post-reset hover re-shows
immediately instead of waiting out the stale fired-latch.
New pinning tests (RetailTooltipPresenterTests: F1/F2/F5/F6/F7/F8) each
verified to fail against the pre-fix behavior via a temporary revert-
and-rerun before being confirmed against the restored fix.
PortalProjectionTests.ProjectToClipLease_ReusesPooledWorkWithoutResultArrays
recurrence logged on issue #346 (already the tracking issue for this
load-sensitive flake) — hit twice under load this review, standalone
26/26, unrelated to #409.
Gates: Release build 0 errors; App suite (live-DAT env) 5416/5413
passed/3 skips (was 5410/5407/3, +6 new tests); Runtime 1735/0;
UI.Abstractions 926/0; full solution (no env, 69 skips expected)
14,623/14,554 passed/69 skipped/0 failed (was 14,617/14,548, +6).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Full re-derivation from named-retail decomp: UIElement::StartTooltipAtMouse
@0x00460D70 -> UIElementManager::StartTooltip @0x0045DE90/@0x00459700,
UIElement::MouseHover @0x00462520 (P0x4B TooltipOn gate + global
m_tooltipEnable), UIElementManager::CheckTooltip @0x0045B6E0 (dwell/
auto-hide timer, default 0.25s/10s), SwitchMouseOver/DeletingElement
(dismissal). Corrects the earlier GF-16 investigation: P0x47 is the
element-desc id WITHIN the popup LayoutDesc (P0x48), not a "behavior
enum"; P0x4A is read off the popup's own instantiated root, not the
trigger element.
- ElementInfo/UiElement gain six tooltip data fields (P0x47/48/49/4A/4B/50),
read generically by ElementReader and copied through LayoutImporter,
mirroring the existing AuthoredInvisible passthrough pattern.
- UiRoot's existing CheckTooltip-derived hover timer gains TooltipShow/
TooltipHide events, a per-element P0x50 delay override, and dismissal
wiring at every retail-confirmed teardown site.
- RetailTooltipPresenter (owned by RetailUiRuntime, mounted alongside
RetailDialogFactory) builds the popup via the existing LayoutImporter
dat-lock seam, auto-resizes by the measured-vs-authored text delta
(word-wrapped via the existing UiText.WrapWords primitive), positions
at the mouse clamped to the display, and stays topmost over dialogs via
its own later per-tick BringToFront (register AD-106).
- Misc.TooltipEnable/Misc.TooltipDelay are client-local UserPreferences
(retail's own 2013 Config tab authors no visible row for either) —
SettingsStore gains a MiscSettings section, no new options-panel row.
- Live-DAT sweep: 434 elements author >=1 trigger property (243 with
literal text this port shows; 191 rely on retail's dynamic
InqProperty(0x49) override, deferred as register TS-85 alongside the
unmodeled P0x3D wrap-width override).
Gates: Release build 0 errors; App suite (live-DAT env) 5410/5407 passed/
3 skipped (was 5379/3); Runtime 1735/0 unchanged; UI.Abstractions 926/0;
full solution 14,617/14,548 passed/69 skipped/0 failed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Campaign LA (2026-08-14/15): Avalonia launcher/installer/updater
(Windows+Linux), retail character-management screen, session-config +
status-stream contract (§LA1), two connected gate rounds USER-PASSED.
Campaign CC (2026-08-15/16): the full retail character-creation flow —
chargen data layer, byte-exact 0xF656 + complete 0xF643 handling,
RuntimeCharacterCreationState, the six-page gmCharGenMainUI screen with
live 3D preview and the real color wheel, RandomizeCharacter open-roll,
launcher payload cycle. Seven slices review-closed; the connected gate's
extended round (GF-1..16, R2/R3/R4 re-tests) PASSED 2026-08-16 on build
1.0.2-cc.o. Milestone: the first live character created by acdream
against ACE.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The extended gate round (GF-1..16, R2/R3/R4 re-tests, fix batches A-G +
closeout + two re-test rounds) closed with the user's pass on build
1.0.2-cc.o. Plan status and ledger flipped; findings doc carries the
full round history.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Four visual residuals from the lead's own live-client captures of
1.0.2-cc.m, all root-caused via decomp + live-DAT evidence:
- R4-1: Skills credits value overlapped mid-caption again. Root cause
was a missing UiLayoutPolicy raw-edge reflow on UiButton's value-child
rect (the child is base-inherited across four sibling buttons of
differing widths, so its baked-in OriginalParentWidth diverges from
the actual 231px-wide Skills credits button) plus an HJustify.Right
value child mapped to Center instead of a real far-edge Right.
- R4-2: the single-sprite scrollbar thumb tiled (GL_REPEAT) instead of
drawing once — DrawTiled was reused for a small fixed marker graphic
whose native size is far smaller than the track-proportional thumb
rect. New DrawThumbMarker draws exactly one native-size instance.
- R4-3: the skills info-box formula line clipped past the surrounding
gold frame's own authored bottom edge (the pane's own raw box is 20px
taller than the frame that visually contains it) — clamp the pane's
Height to the frame's bottom (register AD-105, since retail's
ShowSkillsText has no code relationship to the frame to cite).
- R4-4: the Appearance help text started mid-sentence — the box was
never touched by its page controller, so it kept UiText's chat-style
PreserveEndOnLayout=true default; the scroll model's wasAtEnd check is
vacuously true on its first-ever overflow transition, pinning the
first render to the bottom. Set PreserveEndOnLayout=false (a static
top-oriented report, not a transcript) and wired the box's own nested
authored scrollbar, never wired before.
App suite live-DAT env 5372/3 -> 5379/3 (+7, zero regressions). Runtime
1735/0 unchanged. Full solution 14585/4 skips/1 failure (the documented
Core.Net NakEmission full-solution-only flake, confirmed standalone-pass).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
R3-8: dumped EVERY property present on 0x10000402 (not just P0x17) across
every state, cross-referenced against UIElement_Text::OnSetAttribute's
complete case list (no UIElement_TextInput class exists in retail — the
name field is a plain UIElement_Text/m_filter-bearing field). The full
recognized-property space has no placeholder/prompt mechanism independent
of P0x17. The BaseElement/prototype-inheritance hypothesis is also ruled
out — the existing regression test already probes the fully-merged
ElementInfo (post BaseElement resolution) and finds nothing. The only
StringInfo-kind property present, 0x49, resolves to "Your name can be 32
characters long and cannot contain numbers or symbols." — but 0x49 is
part of the same five-property tooltip family ISSUES #409/GF-16 already
document client-wide (0x48's own DID, 0x21000041, is the EXACT tooltip
popup LayoutDesc #409 cites) — a hover tooltip, not an in-field
placeholder. No code change, per this batch's own "do not invent a
placeholder" contract — third independent negative result on this
question via three different mechanisms. The lead should request a live
retail screenshot before any further investigation.
Also carries the shared live-DAT regression suite for R3-1 through R3-7
(CharacterCreationLiveDatTests.cs holds tests spanning multiple findings
in one file, so they land together) and the RE-TEST 2 findings-doc
closeout writeup for all eight items.
App suite live-DAT env 5358/3 -> 5372/3 (+14, zero regressions). Runtime
1735/0 unchanged (untouched this round). Full solution: 14578 tests / 4
skips / 0 failures.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Re-derived gmCGAppearancePage::DoColorSpots @0x0047d850 and DoGradDisk
@0x0047da90: retail does NOT multiply-tint the swatch/grad-circle's
authored sprite. It builds a fresh composited surface once (CreateLocalSurface
+ Blit), then calls SurfaceWindow::ReplaceColor against old-color
RGBAColor(0,0,0,1) (opaque black — the spot template's own placeholder
fill, live-DAT-pixel-confirmed: the 37x44 "spot" resource has a genuine
solid-black CENTER and a genuine non-black RING) — swapping every exact
opaque-black pixel for the swatch's real color while leaving the ring
untouched. A multiply-tint (Batch G's mechanism) is architecturally wrong:
black multiplied by any color stays black (never recolors the center),
and multiplying the ring's own non-black pixels corrupts them — exactly
the reported "we tint the ring" symptom.
Beyond-count swatches (R3-5b) use a COMPLETELY DIFFERENT authored resource
(enum 0x1000000f, "blank" — pixel-confirmed almost no black at all, i.e.
genuinely different art) shown untinted, and retail's own
pColor->SetVisible(1) is unconditional for all 9 swatches (never hidden).
For Eyes (R3-6), DoGradDisk's Eyes branch blits the "grad plug" icon
(enum 0x10000010) untinted, and SetSelection's own Eyes/non-Eyes tail
never hides m_pGradCircle at all — a correction to this port's prior
"_gradCircle.Visible = !isEyes" line.
Ported via a new ChargenColorSpotComposer (CPU-side decode-once + per-color
bake-and-cache-once through the existing TextureCache.UploadRgba8 seam —
the same shape IconComposer.GetSpellComponentIcon already established for
item icons, just matching black instead of white) and a new opt-in
UiButton.ColorKeyFaceResolver / reuse of the existing
UiDatElement.RuntimeImageTexture seam — both additive. Tint keeps its
existing meaning for every reader/test; the grad circle's Tint stays a
genuine multiply for the non-Eyes case (retail's own Blit_Multiply there).
Wired as a fourth late-bound composition seam (SwatchTextureSource), same
pattern/site as the existing three color-computation seams.
Code-complete, unit/live-DAT-tested (including pixel-level proof of the
spot/blank templates' actual content); the user's connected visual gate
is owed — no client launches this batch.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Retail authors TWO distinct UIElement_Scrollbar thumb shapes.
DatWidgetFactory.BuildScrollbar's existing vertical-thumb detection was
built against chat's own scrollbar (0x10000012) — a 3-slice composite
where the thumb child carries no media of its own and three Type-3
grandchildren supply the top-cap/middle/bottom-cap sprites. The chargen
Skills listbox scrollbar (0x100003f8), Summary's OVERVIEW listbox
scrollbar (0x10000401), the Summary how-to box's scrollbar (0x100002e7),
and the shade slider (0x10000321) all instead author a SIMPLE
single-sprite thumb: the same structural child (Type 1, id 1, not the
inc/dec button) carries its OWN direct media and has ZERO children — the
3-slice-only search found nothing for this shape, so every Thumb*Sprite
stayed 0 regardless of overflow.
Fixed by falling back to the thumb's own DefaultImage when the slice
search finds nothing — additive; a thumb WITH real slice children (chat)
is unaffected. This one fix covers R3-4's three listbox thumbs, R3-7, and
— as a natural consequence of the same structural shape — the shade-slider
indicator half of R3-5(c); no separate fix was needed there.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The info-box title (0x100003fb, Y=435 H=100) and description (0x100003fc,
Y=460 H=100) panes' own authored boxes overlap by 75px, live-DAT-measured
— retail relies on vertical justification, not disjoint rects, to keep
them visually separate. Neither pane authors dat property 0x15, so both
fall to this port's shared unauthored-VJustify default (currently Center).
Byte-traced retail's real ctor default (UIElement_Text::UIElement_Text
@0x004685ff, m_eVerticalJustification = 4) against UIElement_Text::
CalcJustification @0x00467260's actual enum semantics (1=Center, 3-or-5=
the far edge/Bottom, anything else INCLUDING the ctor's own default of 4
= the near edge/Top): the correct unauthored default is Top, not Center —
a genuine client-wide enum-mapping bug in this port. Under Top both panes
render near their own box's top edge (25px apart, no collision); under
Center both cluster toward the middle of their overlapping boxes.
Scoped fix: CharacterCreationSkillsPage force-sets VerticalJustify=Top on
both panes directly, rather than fixing the shared mapping/default — that
bug is client-wide and could regress already-shipped FROZEN surfaces
(vitals, chat, main game UI, Options) that may rely on the current Center
default. The shared fix is filed as ISSUES #410 / register AD-104 for its
own dedicated investigation + regression sweep.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Batch E's UiButton.DrawBlockLabel/WrapBlockLines auto-wrapped any caption
that didn't fit its box width — decomp-wrong. UIElement_Text::
CalcJustification @0x00467260 (shared by GlyphList::Recalculate's
horizontal/vertical branches) shows retail's real per-glyph break decision
(both the width-triggered wrap AND the explicit-newline break) sits behind
ONE gate keyed on the OneLine flag; nothing in the decomp confines a
caption's wrap width to a sibling element's rect (Batch E's own ValueBox
confinement for the coexisting-value-label shape).
Live-DAT evidence: the Coordination attribute-slider label (0x100002ed)
authors OneLine=true (should never wrap); the Skills credits button's
"Available Skill Credits" caption measures 193px against its own full
231px button width (fits comfortably) — the 113px confined width Batch E
fed the wrap decision was never a real retail quantity.
Fixed: WrapBlockLines now splits ONLY on the explicit (already-normalized)
'\n' — never width-based. Strict superset of the pre-Batch-E single-line
draw for every already-correct caption; "Attribute\n Credits" still works.
The ValueBox confinement computation stays in OnDraw (still feeds the
Center-alignment tx formula) but no longer gates the wrap decision.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Doc-only findings from the round review, plus the register rows the
three code commits' own bookkeeping notes reference:
- F3: AP-229 amended with the dialog-as-sibling z-order addendum — the
same flat-sibling-list mechanism that motivates AP-229's own screen-
layering row also covers RetailDialogFactory's open dialogs, which was
GF-15's actual root cause (now fixed, but the underlying divergence —
dialogs and screens sharing one z-order list at all — remains and
could reintroduce the same failure class via a future sibling's own
unconditional per-tick BringToFront).
- F5/F6: AP-230 amended with the second narrow-honor addendum (the
LayoutImporter carve-out fix landed in the Group 3 code commit); the
findings doc's "CHAT INPUT" label corrected to "chat transcript" in
both places it appeared (0x2100006F/0x10000011 is the transcript
display, not the input textbox).
- F12: the AD section header recounted 77 -> 79 (a direct physical count
found it undercounted by 2); the AP section header's own "one high"
drift-direction note corrected to "one low" — verified against the
actual commit history (Batch A ended with 165 physical rows but a 164
header; Batch B's recount correctly landed on 164, the header was
never overcounting).
- F15: ISSUES.md #406 gains the crash-vs-incomplete-shutdown precedence
sentence — ReportExited's _runFailure check runs first and returns
immediately, so a crash always wins over a subsequently-failed
shutdown for the same session's reported reason.
- AP-231 filed (the Group 2 commit's own ComposeFormula connector-text
approximation — referenced in that commit's message but the register
row itself was missed until this pass; 161 active AP rows).
- Campaign CC plan ledger gains a "Gate round 1" row with the full
commit list for batches A-G plus this session's three closeout
commits, superseding the ledger's stale "sole remaining acceptance
step" framing (written before the connected gate ran and found the
GF-1..GF-16 / R2-1..R2-8 findings this whole round fixed).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The remaining code-bearing findings from the round review, F4-F16 minus
the doc-only items (batched separately):
- F4: three client-wide UiButton corpus sweeps (LabelBox path — exactly
the 4 Town buttons, confined to chargen; conflicting custom-selection-
pair + standard Normal/Highlight media — zero found, no gate
tightening needed; per-state label-color map — 209 matches beyond
chargen, confirming AP-222's mechanism has always been broadly active
since it shipped generically in DatWidgetFactory).
- F5/F6: LayoutImporter's Batch C un-consumed-children carve-out now
honors a child's own AuthoredInvisible flag (a narrow honor scoped to
exactly that carve-out, not the general #408 client-wide one) — the
chat transcript's new-text indicator (0x1000048C) was building as a
visible phantom element retail never shows; verified both directions
against the gold-frame pieces, which do not author Invisible.
- F7: BoundedProcessOutputCapture.AppendLine combines the line text and
its trailing newline into one buffer and one file open/write/close
instead of two.
- F9: corrected a stale comment in RuntimeSettingsTargets — #407 split
DisplayModeCatalog's Resolutions/WindowedResolutions in two, so the
fullscreen validator's own narrower list is now DELIBERATELY different
from the Config dropdown's fuller offering, not the "must match" bug
the comment described.
- F10: documented (not changed) why the LabelBox path's default 3px
inset and the face-relative +4px gap in DatWidgetFactory.BuildButton
are deliberately different numbers — neither carries a retail
citation, and moving either to match the other would be an unfounded
guess on a button that currently works correctly.
- F11: Heritage/Profession/Summary/Town description pages now compose
DatRichText.Compose's result ONCE inside their already revision-gated
Refresh, caching the built line list instead of re-wrapping on every
draw call.
- F14: documented (not changed) why PrivateEntityViewportRenderer's
_animatedIds set carrying a reserved-but-never-drawn backdrop id is
harmless — BuildDrawEntities already excludes a null/empty backdrop
from the actual draw list, so the id is never looked up.
- F16: the Summary preview now uses its own render-id pair
(SummaryPreviewRenderId/SummaryPreviewBackdropRenderId, 0xDA11D035/
0xDA11D036) instead of sharing the Appearance page's
(0xDA11D032/0xDA11D034) — confirmed by tracing
FixedEntityTextureOwnerLease through TextureCache to
CompositeTextureArrayCache's shared owner tracker that both pages'
previews share ONE process-wide TextureCache, so sharing render ids
was a real cross-page texture-release collision (either page's own
re-dress or disposal could release the OTHER page's still-active
textures), not a theoretical one.
F3's own register bookkeeping (AP-229 addendum) and F12's register/AD
header-count corrections land in the docs-only commit alongside F15.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Ports the last remaining half of retail's Skills page: the four-bucket
sorted skill list (Specialized/Trained/UseableUntrained/UnuseableUntrained,
UpdateSkillEntry's own iMinlevel <= 1 test), plus the info box's
description + formula completion.
- ChargenSkillDetail/ChargenSkillFormula (Core) thread SkillBase.MinLevel/
Description/Formula from the global SkillTable, exposed via a new
ChargenOptions.TryGetSkillDetail (nullable-with-default parameter, so
every pre-existing ChargenOptions call site compiles unchanged).
ChargenTableReader.Project populates it from the same SkillTable loop
that already builds GlobalSkillCostsBySkillId.
- CharacterCreationSkillsPage.RebuildRows now groups every costable skill
into SkillBucket, sorts each bucket alphabetically by name
(InsertEntrySorted's wcscmp, ported as string.CompareOrdinal), and
builds one Templates[0] header row per bucket ahead of that bucket's
Templates[1] skill rows — DoSkillRecords' own unconditional
4-header-then-populate order. A level change re-buckets the row
(detected per-refresh against each row's own cached bucket, then a
full rebuild with the current selection explicitly preserved).
- RefreshInfoBox now composes description (word-wrapped via
DatRichText.Compose) + the level-gated bonus line (an exact, unwrapped
literal — NOT routed through word-wrap, which would have collapsed its
authored double-space formatting) + ComposeFormula's "Formula : ..."
line (MakeSkillFormula ported with high confidence for the prefix/
per-attribute-term/divisor/bonus-suffix shape; the two-attribute
connector text is a disclosed approximation, register AP-231, since
the decompiled function's own connector literals could not be
recovered byte-exact by this session's static-only tooling).
Register: AP-213 RETIRED (160 active rows). Live-DAT gate: the installed
SkillTable's MinLevel distribution matches the investigation's own
recorded finding exactly (38 entries, 23 useable-untrained / 15
trained-required). 3 new fixture tests + 1 new live-DAT test; 3
pre-existing integration tests fixed (they captured row widget
references before a bucket-changing click, which now rebuilds and
discards those references — a real, correct consequence of the new
model, not a bug).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Lands Batch G's two STOPPED items, making the real palette-color swatch
wheel visually live instead of inert:
- UiButton and UiDatElement gain a per-instance Tint property threaded
into every existing DrawSprite call (defaults to Vector4.One, so every
pre-existing button/element is byte-identical unless a caller sets a
non-identity tint).
- CharacterCreationAppearancePage now sets Tint directly on each color
swatch button and the GradCircle element, replacing the Batch G
flat-fill ChargenSwatchColorTile overlay outright — an opaque
rectangle drawn on top can never reproduce retail's actual
SurfaceWindow::BlitAndColor(..., Blit_Multiply, color) multiply blend,
only a genuine per-instance sprite tint can, so the overlay approach is
deleted rather than layered under the new mechanism.
- CharacterCreationUiController and RetailUiRuntime grow pass-through
properties (AppearancePalSetSource/AppearanceClothingTableSource/
AppearancePaletteColorSource) mirroring the existing PreviewControl
seam, so LivePresentationComposition can wire a DAT-backed
ChargenAppearanceCatalog into the Appearance page (wiring itself lands
with the Group 3 commit, since it shares a file with an unrelated F16
fix).
Register: AP-216/AP-217 RETIRED (161 -> now further reduced in later
commits) — both rows' remaining gaps are closed, not merely narrowed.
CharacterCreationAppearancePageSwatchColorTests updated for the new
Tint-based assertions (two pre-existing assertions were carried over
incorrectly from the old overlay-visibility model and are corrected).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
R2-4/review F1-F2 (gmCGSkillsPage): row click (and arrow click, matching
retail's own post-Increase/DecreaseSkillLevel re-select) now selects a
skill, highlights its row name, and writes the info panes' title (name +
score) and a level-gated bonus line; the description/formula halves stay
unported (SkillBase._description/_formula unreachable from this page's
current data surface, documented on RefreshInfoBox). The listbox's own
authored scrollbar link is wired to its Scroll model (live-DAT-confirmed
at 0x100003F8, matching the "+1 from the listbox" hypothesis). Cost text
now matches SetSkillText @0x00480600 exactly: Untrained's down-cost and
Specialized's up-cost are literal "0", unconditional, where the port
previously rendered blank; the 999-blank gate applies to the up-cost
only, never to a down-cost. Arrow Ghosted/Enabled state (0x1000001a/
0x1000001b) is now gated per branch, including bUntrainable/
bUnspecializable re-derived as "this row's own effective cost is
nonzero" — no new data needed since the page already resolves that cost.
R2-4b (the four-bucket sorted model) is NOT implemented — its Useable-
vs-Unuseable-Untrained split reads SkillBase.MinLevel, confirmed present
in the installed dat (SkillTable_MinLevelDistribution_NeverExceedsTrained)
but not threaded through ChargenOptions/ChargenHeritageOptions/
CharacterCreationRuntimeBindings. AP-213 row records the exact channel a
future fix needs. Also live-DAT-pinned: Templates[0]'s header-caption
child (0x100002f6) resolves as a UiButton, not UiText, in the real dat —
the same UIElement_Button-is-DynamicCast(0xc)-compatible-with-Text quirk
already ported for GF-4b's slider labels.
App suite (live-DAT env) 5321/3 -> 5328/3 (+7, zero regressions).
Runtime 1735/0 unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
R2-5: retail's gmCGAppearancePage::DoColorSpots/SetSelection/DoGradDisk
paint the nine color swatches and the gradient disc with a real,
computed representative color (PalSet-averaged for Hair/Nose+Mouth+
Skin/Headgear/Shirt/Trousers/Footwear at fixed sample indices
0xd0/0xb0/0x520; direct-Palette for Eyes at 0x103), not the static
authored art acdream showed before this batch.
Ports the full palette-to-RGB pipeline: a new pure Core resolver
(ChargenSwatchColorResolver + IChargenPaletteColorSource) backed by a
new ChargenAppearanceCatalog.TryGetColor reading real Palette dat
objects, pinned against the installed EoR dat. CharacterCreationAppearancePage
recomputes all nine swatches + the gradient disc's tint on every
refresh (part/color/heritage change) and paints them through a new
ChargenSwatchColorTile overlay child — a flat-color-fill approximation
of retail's actual recolored-sprite blit, since neither UiButton
(sealed) nor UiDatElement exposes a per-instance sprite tint today.
Two STOPPED items remain outside this batch's file contract before the
mechanism is visually live: (1) wiring PalSetSource/ClothingTableSource/
PaletteColorSource from CharacterCreationUiController.cs (mirrors the
existing PreviewControl seam); (2) a small additive Tint property on
UiButton/UiDatElement for a byte-true recolor instead of the flat fill.
Also ports Nose/Mouth/Skin's single non-interactive representative
swatch, beyond AP-216/AP-217's original six-part scope.
Register AP-216/AP-217 rewritten (not retired — the two STOPPED items
keep them open). Tests: 11 new Core, 6 new Content live-DAT, 8 new
App-layer fixture. App suite 5321/3 -> 5329/3, Runtime 1735/0
unchanged, zero regressions.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
R2-1/R2-6 (description-box text clipped left of the frame, regressed from
Batch C's frame un-consume): root cause was never the un-consume change
itself — the Heritage/Profession/Town/Summary description boxes
(0x100003C4/0x100003E0/0x10000409/0x10000404) all author retail's four
independent text-inset margins (dat properties 0x23-0x26,
UIElement_Text::OnSetAttribute cases 0xf-0x12: margL=9/margR=26/margU=15/
margD=15), which this codebase never read at all, before or after Batch C.
Un-consuming the gold-frame children just made the pre-existing missing-
margin bug visible for the first time (the frame's own left border now
draws around the same x=0 origin text always used). Fixed end to end:
ElementInfo.MarginLeft/Right/Top/Bottom (read in
ApplyCanonicalLegacyProjection, propagated in Merge), UiText.MarginLeft/
Right/Top/Bottom (additive with the pre-existing Padding), a new pure
UiText.ContentOffsetX static consumed by the multi-line draw path's
per-line placement, and matching wrap-width shrinkage in
DatRichText.Compose and BuildText's own authored-multiline path. Scoped to
the multi-line (non-OneLine) path only.
R2-2/R2-3 (Attribute\n Credits renders the literal backslash-n; the live
credit value overlaps mid-caption): two stacked gaps. (1) UiButton
captions never escape-normalized the DAT's literal "\n" — centralized the
normalize into DatWidgetFactory's ResolveAuthoredString (the one choke
point every P0x17 resolution already shares) plus a NormalizeEscapes
helper for the per-state caption loop, so every caller normalizes
identically. (2) UiButton.Label only ever drew one line — retail's
UIElement_Button IS a UIElement_Text with OneLine=false on these buttons,
so a caption should word-wrap/stack like any other Type-12 box. Added
UiButton.DrawBlockLabel + the pure, unit-tested WrapBlockLines. The
value-overlap itself: ValueBox was never wrong (live-DAT-measured correct
child rects) — the caption was drawing unconfined across the button's
full width ("Available Skill Credits" measures 193px in a 231px button
whose value box starts at x=116). Fixed by confining the caption's own
drawable width to stop before ValueBox.X whenever a ValueLabel coexists.
R2-7a (Summary overview listbox missing its scrollbar): pure wiring gap —
the listbox authors a linked scrollbar via dat property 0x72
(ScrollbarElementId=0x10000401) that CharacterCreationSummaryPage's
constructor never resolved, unlike every other UiTemplateListBox owner in
the codebase. Fixed with the same resolve-and-wire pattern.
R2-7b (how-to box scrollbar overlaps text, no thumb): traced to a
downstream symptom of R2-1, not an independent bug — UiScrollbar only
paints its thumb when the linked model has overflow, and the pre-fix wrap
width (un-inset) produced fewer/shorter lines than fit the view. Pinned
directly against the real installed strings/font (Aluvian's how-to text)
that the margin-correct width overflows. No UiScrollbar code changed.
R2-8 (name field should show "[ Name ]"): re-checked the one hypothesis
Batch A's GF-15 closure left open — an authored initial-text string on
the field's own P0x17. Confirmed absent on every state in the installed
DAT. No code change; Batch A's closure stands, now pinned as a live-DAT
regression test.
App suite 5334/3 (was 5321/3, +13, zero regressions). Runtime 1735/0
unchanged. Full solution Release build green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Retail's chargen 3D views (Appearance and Summary) are not black behind
the model: gmCG3DView::Update @0x004EE9D0 constructs a SECOND CPhysicsObj
from the current heritage's HeritageGroup_CG.environmentSetupID field
(acclient.h verbatim struct layout; the decompiler elides the actual field
read, but HeritageGroup_CG::GetSubDataIDs @0x005c05d0 explicitly walks
iconImage/setupID/environmentSetupID by name, confirming the identity) and
adds it to the SAME viewport's creature_mode_objects the player object
lives in, inserted BEFORE the player (whose own re-AddObject happens much
later, at ~0x004ef199, after the full clothing ObjDesc composes). The
backdrop gets no explicit position/orientation/scale — CPhysicsObj::
makeObject(eax_32, 0, 1) leaves it at the scene origin with identity
orientation, same as the player object's own placement. This id was
already parsed as ChargenHeritageOptions.EnvironmentSetupId
(ChargenTableReader.cs) but never consumed anywhere in production (GF-7/
GF-14).
Fixed by:
- ChargenPreviewEntityBuilder.TryBuildBackdrop: builds a plain, unposed
Setup mesh from the heritage's EnvironmentSetupId, returning null for
id 0/unset or an unresolvable Setup (retail's own INVALID_DID gate).
- PrivateEntityViewportRenderer: an optional second entity slot
(SetBackdrop), reserved via a backdropRenderId constructor parameter so
paperdoll and creature-appraisal — which never pass one — cannot
acquire a second entity even by accident (SetBackdrop throws without a
reserved slot). Per-entity mesh-reference/texture-owner lifetime is
factored into a private EntitySlot helper shared by both the main and
backdrop slots. Draw-entity assembly is a pure, directly-testable
helper (BuildDrawEntities) that puts the backdrop first, matching
retail's own AddObject insertion order.
- ChargenPreviewController.Rebuild: rebuilds the backdrop whenever the
HERITAGE changes (narrower than the existing camera-eye-reset gate,
since environmentSetupID is a pure function of heritage, never gender
or appearance selection).
Both Appearance and Summary get the fix from the same ChargenPreviewRenderer
facade — confirmed both pages call the identical gmCG3DView::Update on
their own gmCG3DView instance, so no page-specific code was needed.
Lighting was independently re-verified against the same function's
SetLight call (DISTANT_LIGHT, intensity 2.0, direction (0.3, 1.9, 0.65),
default white color) and found to already match byte-for-byte what CC6a
shipped.
Also files docs/ISSUES.md #409 for GF-16 (client-wide UI tooltip system),
investigated in the same root-cause pass but explicitly out of this
batch's scope, and marks it DEFERRED in the findings doc.
Tests: 11 new/extended (ChargenPreviewEntityBuilderTests.TryBuildBackdrop_*,
ChargenPreviewControllerTests backdrop rebuild/swap/absent/no-op cases,
PrivateEntityViewportRendererDrawOrderTests pinning the paperdoll/creature-
appraisal single-entity invariant). Live-DAT measurement: all 13 retail
heritages' EnvironmentSetupId resolve to a real, drawable installed Setup.
App suite 5307/3 -> 5321/3 (+14, 0 regressions). Runtime 1735/0 unchanged.
Launcher.Core.Tests 337/0 and Launcher.Tests 67/0 unchanged (first build of
the merged tree carrying the #406 launcher merge). Full solution: 14508
total / 14504 passed / 4 skipped / 0 failed, dotnet test exit code 0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The crashed-client 'graceful' status line was the CLIENT's own Dispose-path
self-report, not the launcher's observation; Run() now latches the escaping
failure and the shutdown report writes reason:'crashed'. Sessions also gain
a bounded client.err.log beside status.jsonl on both spawn paths.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Commit 3/3: Summary how-to text + Commit 2's owed scrollbar linkage +
bookkeeping sweep.
- Ports gmCGSummaryPage::SetHowToText @0x0047ae20 into the Summary
page's how-to box (0x10000404, HowToTextId was declared and unused
since CC5). Retail concatenates ID_CharGen_SummaryHowTo + a heritage/
gender-specific name-suggestion list (heritages 1-4 — Aluvian/
Gharundim/Sho/Viamontian — only; heritages 5-13's cases in the same
switch decompile to a vtable-slot artifact, the same decompiler-
mangled-symbol class the Heritage page's own BonusSkillsKeyByHeritage
table already documents, so no name-suggestion string exists for them
and none is invented) + ID_CharGen_SummaryHowToEnd, directly
concatenated (no separator literal) into ONE plain SetText call — no
per-run font/color argument, unlike Heritage's ...WithFont calls, so
this routes through DatRichText as a single DefaultColor segment.
- Wires the description boxes' linked scrollbar to actual text
scrolling — Commit 2 made the scrollbar child (0x100002e7) BUILD as a
real UiScrollbar; this binds scrollbar.Model = text.Scroll, the exact
pattern ChatWindowController already uses for the chat transcript.
Live-DAT-measured: only Heritage's description (0x100003c4) and
Summary's how-to box (0x10000404) actually author this child —
Profession/Town's shorter description boxes do not (a genuine retail
authoring fact, not something to "fix" further).
Register: AP-215/AP-216/AP-217 rewritten (Batch C's Commit 1 already
retired AP-218/AD-103) — no further changes needed this commit; ISSUES
#366 (chat's new-unseen-text indicator, 0x1000048C under the chat
transcript 0x10000011) NARROWED — its own pre-filed "fix shape"
recommendation (a UiText child carve-out mirroring UiMeter's) is
EXACTLY what Commit 2 shipped, confirmed by that commit's own
client-wide sweep; #366 stays open for the still-missing behavioral
half (no controller drives the indicator's visibility/click).
Findings doc updated: GF-2/GF-3/GF-4/GF-6/GF-11a/GF-12/GF-14's text
half all marked FIXED with their own root-cause notes; the two
remaining "suspected shared roots" (frames/labels, rich text) marked
CONFIRMED + CLOSED.
Full App suite (Debug and Release, live-DAT): 5307 passed / 0 failed /
3 skipped (up from 5304 after Commit 2). Runtime suite: 1735/0,
unaffected.
Campaign CC gate round 1 Batch C is CODE-COMPLETE across all three
commits — GF-2, GF-3, GF-4, GF-6, GF-11a, GF-12, and GF-14's text half
are fixed; AP-216/AP-217 partially closed (register-honest about what
shipped vs what needs a palette-to-RGB pipeline this batch didn't add).
Pending the user's visual gate, with chat + the main game UI flagged
for extra attention (Commit 2's client-wide blast radius).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Commit 2/3: CLIENT-WIDE blast radius — un-consume media-bearing dat
children on UiText/UiField.
UiText.ConsumesDatChildren (true unless a state authors PassToChildren)
and UiField.ConsumesDatChildren (true, unconditional) used to drop
EVERY dat child at import time, including ones that carry their own
renderable media — retail's UIElement_Text/Field genuinely composites
those as real chrome/controls (frame pieces, linked scrollbars), not
swallowed caption/face art the way a Button's or Meter's children are.
LayoutImporter.BuildWidget gains a new carve-out (mirroring the
existing UiMeter one): when a UiText/UiField's ConsumesDatChildren is
true, build any child whose OWN StateMedia is non-empty (it carries a
real sprite/track) instead of dropping it outright. Purely structural/
property-only children (StateMedia.Count == 0) stay dropped exactly as
before — this is additive, not a relaxation of the PassToChildren gate.
Independently re-derived blast-radius sweep (walks every installed
LayoutDesc via DatCollection.GetAllIdsOfType<LayoutDesc>, new
LayoutImporterMediaBearingChildSweepTests): 37 distinct (layout,
element) pairs — 41 raw tree positions, since a handful of element ids
recur at multiple subtree positions within the same layout — across 15
layouts. Full list:
0x21000005/0x10000011 (x5 tree positions — chat-adjacent template
reused across the layout), 0x21000005/0x1000059A [MAIN GAME UI],
0x21000006/0x10000011, 0x2100000F/0x1000059A,
0x21000038/{0x100003AB,0x100003BA,0x100003C4,0x100003E0,0x100003EC,
0x100003F6,0x100003FA,0x100003FD,0x100003FF,0x10000402,0x10000404,
0x10000405,0x10000409} [character creation],
0x21000043/0x10000362,
0x21000046/0x100003C4, 0x21000047/{0x100003E0,0x100003EC},
0x21000048/{0x100003F6,0x100003FA,0x100003FD},
0x21000049/{0x100003AB,0x100003BA}, 0x2100004A/0x10000409,
0x2100004B/{0x100003FF,0x10000402,0x10000404,0x10000405},
0x2100004C/{0x100002DD,0x100002E5,0x100002E6},
0x2100005B/0x10000011, 0x21000068/0x1000059A,
0x2100006F/0x10000011 [CHAT INPUT].
(This is an independent re-derivation, not a re-statement of the
investigation's earlier "42/14" estimate — the small difference is
expected from measuring with this commit's own criteria.)
New tests: the sweep itself (pins the two flagged landmarks —
MAIN GAME UI 0x21000005/0x1000059A and CHAT INPUT 0x2100006F/
0x10000011 — plus the three chargen boxes), a build-through regression
test confirming those two landmarks' children resolve as real widgets
post-fix, and a chargen-scoped test confirming the eight gold-frame
pieces + linked scrollbar on all three description boxes now resolve
via UiElement.FindDescendant.
Full App suite (Debug and Release, live-DAT): 5304 passed / 0 failed /
3 skipped — ZERO regressions across the whole client, including every
existing chat and main-UI test. Runtime suite: 1735/0, unaffected
(this is an App-layer-only change).
FLAG FOR THE LEAD: automated coverage cannot catch a purely VISUAL
regression (a frame drawing in the wrong place, a scrollbar overlapping
text). Chat and the main game UI both got new dat children rendered for
the first time this commit — schedule the user's own visual check of
both before considering this closed, per the campaign's oracle
discipline.
The scrollbar linkage (wiring the description boxes' UiScrollbar to
actual text scrolling) is NOT done in this commit — the scrollbar
widget now BUILDS, but CharacterCreationHeritagePage/TownPage/
ProfessionPage/SummaryPage do not yet bind its ScalarChanged to
UiText.Scroll. Filed as follow-up (see report).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Commit 1/3: chargen-scoped, low blast-radius fixes.
- New DatRichText helper: escape-normalize + word-wrap + per-segment
palette color, porting UIElement_Text::SetStringInfoWithFont /
AppendStringInfoWithFont's composition model. Routes the Heritage
(GF-2), Town (GF-11a), and Profession (GF-3) description boxes
through it instead of a raw unwrapped single-Line LinesProvider.
Heritage headers use font-color palette index 1 (green), bodies
index 0 (white), matching AppendStringInfoWithFont's own font-index
argument. Town's diagnosed GF-11a root cause: a single un-wrapped
line meant the town-specific suffix rendered past the clipped
viewport, so switching towns looked like "text never changes" even
though the underlying composed string genuinely differed.
- GF-3: bind the Profession page's description textbox (0x100003e0,
gmCGProfessionPage::InitializePage @0x00483068) and compose its
per-template text (UpdateProfession @0x004821b0's CustomText/
BowText/SwashText/LifeText/WarText/WayText/SoldierText, plain
SetStringInfo — no palette).
- GF-4: UiButton gains a coexisting ValueLabel/ValueBox/ValueFont/
ValueColor slot alongside Label. Retail's chargen display buttons
(avail/health/stamina/mana credits, 0x100003e2-e5/0x100003f9)
author their caption directly on P0x17 AND carry a separate,
media-less Type-12 value child that UiButton.ConsumesDatChildren
used to drop entirely — pages substituted the button's own Label,
destroying the caption. DatWidgetFactory.BuildButton now surfaces
that child (gated on ReferenceEquals(labelInfo, info) — own-caption
buttons only) instead. The six Profession slider name labels
(0x100002ed, CharGenState::GetAttributeName @0x005C3A20's six
hardcoded literals) resolve as UiButton in this port (live-DAT-
measured Type 1 — retail's UIElement_Button is DynamicCast(0xc)-
compatible with UIElement_Text) and are written once at
construction, matching retail's own single InitializePage write.
- GF-6/AP-218: gmCGAppearancePage::Update writes a heritage-flavored
STATIC caption to the Hair/Eyes/Skin spins (plain / GearText_* /
OlthoiText_* variants) — never an index. Removed the prior 1-based-
ordinal/gear-name substitution entirely; the other six spins keep
their DAT-authored caption untouched, matching retail exactly.
- Root 1d: wire the Heritage (0x100003be, 13 states) and Profession
(0x100003d8, 7 states) backdrop SetState cascades
(gmCGHeritagePage::Update / gmCGProfessionPage::UpdateProfession).
- AP-216/AP-217 (partial, register updated honestly): swatches beyond
the current part's real color count now hide (DoColorSpots' blank-
blit half); the GradCircle now blanks for Eyes (DoGradDisk's blank-
plug half). The "paint with the actual represented/current color"
halves stay open — they need a PalSet/Palette-id -> RGB pipeline no
chargen page reads at runtime yet, judged disproportionate to add
alongside this batch's other ~10 fixes.
Register: AP-215 rewritten (item 2's "ordinal" framing is stale after
GF-6; restated as the icon-thumbnail gap), AP-216/AP-217 rewritten
(partially closed), AP-218 retired, AD-103 retired (the swallowed-
child Label substitution AD-103 tracked is replaced by ValueLabel's
own-geometry surfacing).
22 new tests (DatRichText unit tests, UiButton/DatWidgetFactory
ValueLabel tests, live-DAT structural pins, controller behavioral
tests) — all green. Full App suite (Release, live-DAT):
5300 passed / 1 pre-existing unrelated flake (PortalProjectionTests
allocation test, passes in isolation) / 3 skipped, up from the
baseline 5282/3.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
GameWindow.Dispose() (via Program.cs's `using var window = ...`) runs
unconditionally even when invoked mid-unwind of an exception that escaped
Run()'s Silk.NET frame loop. Resource teardown itself can converge
cleanly regardless, so CompleteShutdown had no way to tell "normal Run()
return" from "a crash is propagating through me right now" and always
wrote the hardcoded exited{code:0,reason:"graceful"} — exactly the
symptom #406 observed against a real 0xE0434352 crash. Fixed by latching
_runFailure in Run()'s existing catch block (before the pre-existing
throw) and consulting it from a new ReportExited method, the one call
site for the terminal status write: crashed(1)/graceful(0)/
shutdown-incomplete(1) as appropriate. No wire-contract amendment needed
— §LA1 pins the exited event NAME, and reason is already free text that
StatusEventParser round-trips unchanged.
Sibling gap fixed in the same commit: the launcher discarded the child's
stdout/stderr entirely, which is why diagnosing this exact crash required
a manual console re-run. Added BoundedProcessOutputCapture, a 2 MiB-capped
sink mirroring SessionStatusWriter's open-append-flush-close-per-write
posture (a long-lived write handle is not actually concurrently readable
on Windows even with FileShare.Read — confirmed by isolated repro), wired
into both SystemChildProcess (ProcessStartInfo.RedirectStandardError;
Linux + Windows graphical children, i.e. this bug's own scenario) and
WindowsSystemChildProcess (a real native pipe via CreateChildOutputPipe,
mirroring the existing stdin pipe; Windows console-capable/Headless
children). Opt-in via LauncherProcessSpec.StderrLogPath (null = unchanged
behavior), threaded through SessionConfigComposer -> client.err.log
beside status.jsonl -> LauncherExecutableSet -> LauncherOrchestrator.
Tests: GameWindowCrashStatusTests (source-shape, matching the existing
GameWindow test pattern — the class cannot be constructed without a live
GPU/window), BoundedProcessOutputCaptureTests (10 unit tests), and three
new LauncherProcessSupervisorTests spawning real child processes through
both capture code paths.
Launcher.Core.Tests: 337/0 (was 324/0). Launcher.Tests: 67/0 (unchanged).
Full solution build green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
GF-1/GF-8: UiButton now recognizes retail's custom Unselected/Selected
radio-pair (0x10000016/0x10000017), bypassing the standard Normal/
Highlight machine that never admitted those state names — .Selected now
lights the heritage/template/gender/Face-Clothes rows it was always a
no-op for.
AP-222/GF-11b: per-state label color/outline (dat 0x1B/0x21) now applies
off the REQUESTED retail state id, not the art-gated committed
ActiveState — resolves the Appearance spins' current-part highlight
(text recolors even though no Highlight art exists on either client) and
the Town caption's Normal-to-white swap.
GF-11c: UiButton.LabelBox lets a lifted caption with its own authored
rect draw there instead of the face-relative offset that's only correct
when the label is authored directly on the button (heritage/template
family, unchanged).
GF-9: wires the real nine companion overlay elements (SetColor's
SetVisible mechanism) that swatch clicks were always meant to drive,
retiring AP-215 item 1 (the swatch.Selected substitution was a permanent
no-op — swatches author no Highlight media at all).
GF-10: zoom buttons now set the retail-mirrored mutual-exclusive
Highlight/Normal pair on click; InitializePage carries no initial
SetState for either button, so both stay at "Normal" until first click.
Register: AP-222 retired (mechanism identified and ported), AP-215
narrowed (item 1 retired, item 2 unrelated and unchanged), row count
recount corrected 164 (was already one high before this batch).
App suite 5282/3 (was 5266/3), Runtime 1735/0 unchanged. Fixture + live-
DAT tests only — no graphical client launch.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
GF-15 (the gate blocker): the Summary name field and Finish button were
NOT structurally broken — live repro over the project's own local ACE
test server showed clicks correctly focus the field and land characters.
The real bug only surfaces after the first dialog opens: pressing Finish
empty successfully creates the NoName RetailMessageDialogView (visible,
correct 400x95 geometry) but it renders nothing and silently absorbs
every click across the whole canvas. Root cause: CharacterCreationUiController.Tick
and CharacterManagementUiController.Tick both call UiRoot.BringToFront(Root)
unconditionally every frame (needed so chargen stays above the occluded
management screen, AP-229); a dialog root is a direct sibling under the
same UiRoot, and RetailWindowManager.BringToFront is "highest ZOrder among
siblings + 1" — whichever BringToFront runs last in a frame wins.
RetailDialogFactory.Tick never re-asserted its own dialogs' z-order, so
the next frame's screen Tick buried the dialog behind the screen's opaque
backdrop while it stayed the registered Modal with exclusive input
priority. Fixed by having RetailDialogFactory.Tick re-raise every open
dialog (in open-order) each tick, matching retail's always-on-top dialog
behavior. Live-verified the complete user sequence end to end: click
field, type, press Finish empty, dialog now visibly renders, OK dismisses
cleanly, field still typable afterward. The "[ Name" prefill question is
closed as a non-bug: neither CharGenState::RandomizeCharacter nor
gmCGSummaryPage::InitializePage write text into the field in the decomp;
retail's field is genuinely empty on open, matching acdream already.
GF-5: CharacterCreationSkillsPage.RebuildRows resolved the wrong listbox
template (Templates[0], retail's own 3-child bucket-header row) and
required the root to be a UiButton (it's a plain container). Byte-traced
gmCGSkillsPage::DoSkillRecords + tagSkillRecord's copy-ctor field order
to map every child id in the real row (Templates[1]): name, level/cost
text, and the two real per-row up/down arrow buttons. Wired the arrows to
retail's own plain-click dispatch, retiring (narrowing) AP-213's
click-to-advance/double-click-retreat single-button substitution.
GF-13: dat property 0x3B (Invisible) was never read by the importer.
Elements 0x10000403/0x10000494 ("Non-Admin"/"Non-Envoy") author it true.
A blast-radius sweep found 1,083 elements client-wide author the same
flag, so this fix stays chargen-scoped only (ElementInfo.Invisible /
UiElement.AuthoredInvisible are pure data additions; only
CharacterCreationUiController acts on them, by the authored flag, not a
hardcoded id list). General importer-wide honor filed as ISSUES.md #408;
register row AP-230 records the split.
Gates: solution build green; App 5266/3 skips/0 failed; Runtime 1735/0;
full-solution run 0 failures anywhere. Register: AP-230 filed, AP-213
narrowed. ISSUES: #408 filed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Campaign CC gate round 1. The Config Resolution dropdown now offers
DisplayModeCatalog.WindowedResolutions — the curated hardware modes
UNIONed with the static modern-ladder sizes that fit the desktop —
because a windowed pick is a plain Size write needing no video mode,
and remote/RDP virtual displays advertise almost none (the live RDP
display exposed exactly 1920x1080 + the 2056x1290 desktop, leaving the
dropdown with nothing below 1920). The fullscreen apply still validates
against the hardware Resolutions list plus the switcher's
monitor-mode-list hard guard, so a fullscreen pick of a windowed-only
entry refuses safely (log-and-stay, #388/#392) — IA-22's
offered-implies-supported invariant narrows to the fullscreen half and
its register row carries the amendment. Three new pure-union tests
including the exact live RDP shape.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Campaign CC gate round 1, second finding. The RDP virtual display
advertises exactly two video modes (1920x1080 + the 2056x1290 desktop),
so #391's curated catalog — correct for fullscreen mode switches —
leaves the WINDOWED size dropdown with nothing below 1920. Windowed
sizes need no video mode; the fix splits the offering by target state
(union list for the dropdown, hardware-gated validation only for the
fullscreen apply). Fix lands with this gate round's batch; live
workaround confirmed: drag-resize.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Campaign CC gate round 1, first finding. CC6b-MOUNT's chargen preview
lease and CC5's summary preview lease both rode into the published live
presentation beside the paperdoll/appraisal siblings but never got their
Transfer() calls in CompletePresentation's ladder, so the composition
scope's unpublished-resource leak guard threw on every real window load
with retail UI mounted (launcher path and dev path alike) and the client
died before connecting. Two-line fix at the ladder; verified by a live
launch reaching started/connected/characterList with a graceful close.
Also files #406: the launcher recorded this crash as exited{code:0,
reason:graceful} — the session orchestrator's exit observation is wrong
and misled the first diagnosis; the console repro showed the real
0xE0434352.
No automated suite executes the transfer ladder (needs a live GPU
window) — the coverage gap is recorded in #405's closing note.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
All seven slices (CC1-CC7) are REVIEW-CLOSED; the campaign is
CODE-COMPLETE. The sole outstanding acceptance step is the user's
connected gate (docs/research/2026-08-16-campaign-cc-test-script.md).
Fills the CC7 ledger row's literal fix-round sha (2176ba76) per the
established follow-up-docs-commit pattern, and adds the Campaign CC
block to CLAUDE.md Current state (the CC7 review's F10 observation,
deferred to this lead closeout commit).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Both dual-lens reviewers of `9cf6c522`+`ddcbf1fb` returned PASS-with-items.
This round closes all nine findings:
F1 files AP-229 for the screen-layering divergence (retail destroys/
reconstructs the current UI framework via UIFlow::UseNewMode; acdream
keeps both CharacterManagementUiController and CharacterCreationUiController
mounted for the whole lifetime and reveals/occludes) plus its narrow
residual risk (the shared RetailDialogFactory can hand UiRoot.Modal to a
dialog opened by the still-ticking, occluded management screen on an
inbound CharacterError) and what already matches retail (selection/
world-name persistence, click-through isolation, one coherent Modal
stack).
F2 rewrites the connected-gate script's roster-full step with the exact
`@modifylong max_chars_per_account` recipe and the pending-delete-counts
note. F3 adds AP-221's console-diagnostic lines to the known-gaps
paragraph. F4 adds an empty-name/AP-227 step. F9 notes that a uniform
Random pick over 13 heritages can repeat.
F5 adds an App-layer source-text pin
(GameWindowLiveSessionOwnershipTests.LiveSessionRuntimeFactoryBinds
CharacterCreatedAndCreationFailedToTheStatusWriter) for the delegate
wiring the reviewer proved was deletable without breaking any test — no
practical seam exists to construct LiveSessionRuntimeFactory without a
GameWindow, so this follows the file's own established source-text-pin
pattern; the payload shape is already pinned separately at
SessionStatusWriterTests.
F6 corrects the CC7 ledger's checksum-assertion wording (it is a
round-trip purity check, not an independent golden — the golden is
CharacterCreateTests.ComputeChecksum_ExactRetailAccumulationSet) and
cross-references it from the test's own doc comment.
F7 corrects the CC7 ledger's fixture-ordering claim (it had chargen
constructing first, backwards from RetailUiRuntime.Tick's real
management-then-chargen order) and reorders CharacterScreensFixedCanvas
ArbiterTests to match production, adding ClickThrough/ZOrder assertions
that pin the occlusion the reviewer previously verified only by hand.
F8 records a known flake (RuntimeCollisionReportingStateTests.
WarmedSteadyContactRefreshDoesNotAllocate, allocation-assertion load
sensitivity, pre-existing) seen under full-solution parallel load on
both reviewer runs.
Campaign status: all seven slices (CC1-CC7) are REVIEW-CLOSED; the
campaign is CODE-COMPLETE pending the user's own connected gate.
Runtime 1735/0 (unchanged), App 5257/3 skips (+1: the new F5 pin).
Full Release build: 0 warnings, 0 errors.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Records CC7's implementation (Create-button un-ghosting, the full-flow
wire tests, the launcher payload cycle verification, the 4 pre-existing
LiveSessionControllerTests fixes, the AP-211 register update, the
connected checklist doc) against commit 9cf6c522, and flips the plan's
header Status line to reflect all seven slices (CC1-CC7) code-complete
pending CC7's own dual-lens review and the user's connected gate.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Create button un-ghosts: retail's exact gate (gmCharacterManagementUI::
UpdateButtons @0x004ec240, roster count < allowed slot count) ported into
RuntimeCharacterSelectionButtons.CanCreate; the button's OnClick opens the
chargen screen through the same CharacterCreationUiController.Open() seam
the ACDREAM_OPEN_CHARGEN=1 dev path already used. Exit/Back confirm on
chargen needed no new return-path code — character-management is never
hidden while chargen is open on top of it — verified end-to-end by a new
cross-controller test rather than left as an inspection claim.
Full-flow test coverage: a new comprehensive test decodes every 0xF656
field (including the trailing checksum, recomputed via the production
CharacterCreate.ComputeChecksum) against a fully populated creation
(heritage/gender/all appearance slots/template/explicit skill command/
town/name); a new Theory drives the remaining six 0xF643 rejection codes
through the real wire decode path, closing the gap between the
already-covered isolated state-machine Theory and an actual WorldSession
round trip.
Launcher payload cycle: two new tests drive a real Runtime create/reject
through the real SessionStatusWriter (wired exactly as
LiveSessionRuntimeFactory/HeadlessSessionHost do in production) and read
the result back with the real Launcher.Core StatusFileTailer/
StatusEventParser — closing the one gap CC2's own per-layer tests never
reached. No gap was found in production wiring itself: GameWindow already
constructs a real, non-null SessionStatusWriter for both hosts.
Also fixes 4 pre-existing LiveSessionControllerTests assertions that
compared a full RuntimeCharacterSelectionButtons record and would have
failed once CanCreate started being computed; corrects register row
AP-211 to reflect that its own predicted resolution (the Create-button
gate landing) has now happened — both layers are intentionally kept as
retail-matching enforcement plus defense-in-depth, not one superseding
the other.
Adds docs/research/2026-08-16-campaign-cc-test-script.md, the user's
connected-gate script covering both the launcher and dev-shortcut launch
paths, the six-page create flow, every Finish outcome, and the known
cosmetic/behavioral divergences (AP-212/213/215/216/217/218/219/220/222/
224/226/228) so they aren't mistaken for new bugs during the gate.
Gates: full solution Release build green; Runtime 1735/0 (was 1726/0,
+9), App 5256/3 skips (was 5254/3, +2), Headless 166/0 (unchanged),
Launcher.Core 324/0, one full-solution pass across every project clean
(no known flakes reproduced this run).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fills in 356545c5 now that it exists, matching a975efd1/2d4168f9's own
pattern of a follow-up docs-only commit for a fix round that cannot
self-reference its own sha.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The narrow re-review of fix commits 0c8e1e7d+2d4168f9 found every code fix
oracle-verified but returned NOT CLOSED on five test/doc residuals plus
three nits and a follow-up filing. All fixed:
- R1: the claimed "external-change->user-commit->SetName" regression test
for F1 (the deleted _suppressNextFieldEvent latch) never existed — the
Runtime-layer randomize test doesn't touch the page. Added
CharacterCreationUiControllerTests.SummaryNameField_RealCommitAfter
ExternalRefreshWhileUnfocused_StillReachesSetName: drives Refresh with a
revision bump + changed snapshot.Name while the field is unfocused (the
programmatic SetText path that used to arm the latch), THEN performs a
real user commit (field.SetText + field.Submit(), the actual event path),
asserting SetName receives the player's typed text.
- R2: RetailSkillFormula.CalculateChargenScore and ChargenSkillScoreResolver
had zero direct coverage (the F12(d) test substitutes skillId*10). Added
a Untrained/Trained(+5)/Specialized(+10) theory, the divisor-zero skip
path, and a six-way AttributeId theory (Str=1..Self=6) to
RetailSkillFormulaTests.cs.
- R3: RetailSkillFormula.cs's doc comment claimed "no retail-authored skill
sets MinLevel above Untrained=1" without ever reading the field — ACE's
own SkillBase.cs hedges the same field "// 1-2?". MEASURED (not assumed)
against the installed EoR dat's global SkillTable
(CharacterCreationLiveDatTests.SkillTable_MinLevelDistribution_
NeverExceedsTrained): 23 skills at MinLevel 1, 15 at MinLevel 2, zero
above 2, of 38 priced skills. ACE's hedge was right; the doc comment now
states the measured fact and leans on the structural argument (the gate
holds for Trained/Specialized under any MinLevel in {1,2}) as load-
bearing, not the unverified data claim.
- R4: filed AP-228 — the Summary/Skills skill-row KEY sources from
ItemAppraisalTextFormatter.SkillName's hardcoded English switch, where
retail's own key is DAT-sourced (SkillBase->_name via %hs,
0x0047b90f-0x0047b915) — same divergence class as AP-226 filed the same
round, reversed polarity, also present at CC4's Skills page. Softened
AP-224's "ported exactly, not simplified" claim: it only ever covered the
row's VALUE/template, never its KEY.
- R5: this commit corrects 0c8e1e7d's gate claim. "Release build zero
warnings" was false: a clean `dotnet build -c Release -t:Rebuild` shows
25 pre-existing warnings (18 in tests/AcDream.Core.Tests, 7 in
tests/AcDream.App.Tests — Composition/HostInputCameraCompositionTests.cs,
Composition/WorldRenderCompositionTests.cs,
UI/Layout/OptionsPanelLiveMountProbeTests.cs), none in any file this
campaign or its residual round touched. History is not amended; this is
the correction.
Nits: the ChargenPreviewController ctor doc now also cites
gmCGSummaryPage::Update @0x0047baa0 (the per-heritage re-derive site — 0xc
Olthoi/0xd OlthoiAcid/else — not just the one-shot InitializePage seed) as
the stronger justification for why Rebuild re-derives the zoomed-out eye
per heritage on every change. RuntimeCharacterCreationState's F2 comment
("Finish becoming a permanent no-op") reworded: the same unconditional
_verificationPending = false assignment ran pre-fix too, so Finish was
never blocked — only the response FEEDBACK vanished (no dialog, no created
character, nothing), not the request itself. Filed #404 for
ChargenSkillScoreResolver's own independent SkillTable read alongside
ChargenTableReader's (cleanup follow-up, out of this round's scope).
Ledger: CC5 flipped REVIEW-CLOSED in the campaign plan (dual-lens
architectural PASS-with-items / retail-fidelity FAIL -> F1-F14 fix round
0c8e1e7d -> narrow re-review: all code oracle-verified, residuals R1-R5
test/doc -> this commit; re-reviewer pre-authorized lead diff-check close).
This commit's own sha is recorded by a follow-up ledger-only commit,
matching 2d4168f9's own pattern.
Gates: Release build 0 errors (25 pre-existing warnings, unrelated to this
round — see R5 above); App suite 5257/3 skips (was 5242/3), 0 failed;
Runtime suite 1726/0 (unchanged); the three new/measured tests (R1, R2's
ten cases, R3) all pass individually.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fills in the sha for 0c8e1e7d now that it exists; the previous commit
could not self-reference its own hash.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Opus dual-lens review of 34e3a534+a975efd1 returned architectural
PASS-with-items / retail-fidelity FAIL. Every finding fixed:
- F1 (BLOCKER): deleted CharacterCreationSummaryPage's dead
_suppressNextFieldEvent latch. UiField.SetText never raises
OnFocusLost/OnSubmit, so the latch never had anything genuine to
suppress — it stayed armed until the player's own next real commit
and silently ate their typed name.
- F2: byte-re-derived gmCharGenMainUI::RecvNotice_
CharGenVerificationResponse @0x004e9030's jump table — Pending is an
explicit switch case landing on the SAME NameDBDown label as
Corrupt/DatabaseDown, and Undef/out-of-range falls through the
function's own unsigned-underflow default arm to that identical
label. Retail's dispatch has NO silent branch. ApplyCreationResponse
now produces a real rejection for Pending/Undef instead of a silent
reset; ReconcileDialogs maps them to NameDBDown. Corrects the wrong
"retail swallows Pending" claim everywhere it was repeated (plan doc,
Core.Net doc comment, Runtime doc comments).
- F3: skill rows now use the key/value template with
CharGenState::GetSkillScore @0x005C4B50 as the value (ported via the
new RetailSkillFormula.CalculateChargenScore /
ChargenSkillScoreResolver, wired through a new GetSkillScore
binding), not template 0/name-only; bucket headers are unconditional.
Writing this fix's own regression test surfaced a second, more severe
bug: CharacterCreationSummaryPage never wired _list.TemplateResolver
at all, so RebuildListbox has been a silent no-op since CC5 shipped —
fixed by threading templateResolver through the page's constructor,
matching every sibling UiTemplateListBox owner.
- F4: added the missing _errorMessageDialogContext one-outstanding
guard to the 0xF643 rejection dialog, matching
MakeErrorMessageDialog's own guard @0x004e8cc4 and the other four
sibling dialogs' shape (registered in CloseAllDialogs, suppress-
callback checked).
- F5: the Summary preview camera now seeds/re-derives retail's
zoomed-OUT eye (byte-decoded (0,-2.5,0.95) at gmCGSummaryPage::
InitializePage ~0x0047bd14-0x0047bd44) instead of Appearance's
zoomed-in default, via a new ChargenPreviewController
useZoomedOutEye flag.
- F6: retired AP-225 outright — re-derived the ListenToElementMessage
length gate is NUL-inclusive, so MaxNameLength=32 was always
byte-correct, not merely internally consistent.
- F7: amended AP-221 to cover the Summary preview's duplicate
one-shot-composition binding gap (CC5 duplicated the pattern instead
of closing it).
- F8: byte-decoded GetRandomReal @0x00563940's fmul operand at
0x007cd650 — an 8-byte double, not a 4-byte float — is EXACTLY
1.0/32767.0, not 1/32768. Added RollShadeLocked
(_random.Next(32768) * (1.0/32767.0)) and switched all six shade
rolls onto it.
- F9: evaluated porting retail's exact empty-name-commit no-op
(NUL-inclusive length==1 skips SetName entirely) and rejected it —
it would fight the F1 field-sync model by spontaneously reverting an
emptied field on the next unrelated revision bump. Kept the clear,
documented the tradeoff, filed AP-227.
- F11: filed AP-226 documenting retail's static pcProfessions/pcGender/
pcHeritage/pcTown label tables versus acdream's DAT-sourced labels,
including the non-human-heritage-renders-bare-"Heritage:" retail
quirk.
- F12: added exclude-current determinism (count-2 lists), Random-
clears-name, repeat-identical-rejection-reshows, and RebuildListbox
content tests (the last one found F3's TemplateResolver bug).
- F13: threaded an optional Random through GameRuntimeDependencies ->
LiveSessionController -> RuntimeCharacterCreationState, matching the
existing TimeProvider injection shape, closing the Slice-K
determinism hazard on a bot-reachable Randomize* command family.
- F14: RandomizeCharacterLocked now assigns _heritageId unconditionally
before the TryGetHeritage gate, matching retail's SetHeritageGroup
@0x005C67A0 (mHeritageGroup written before the DAT lookup).
Gates: Runtime 1726/0 (was 1722/0), App 5242/3 skips (was 5240/3),
Headless 166/0, Core.Net 993/994 (the one failure, NakEmissionTests
LossSoak, is a known pre-existing flake — passes standalone), full
solution Release build green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Follow-up to 34e3a534: the ledger row was written before the commit
existed, so it referenced "this session's commit(s)" as a placeholder.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fills TS-82's Summary placeholder with a faithful port of gmCGSummaryPage
(name field with NameInputFilter + the retail commit-on-focus-lost/submit
dispatch + the >32-char ID_CharGen_NameTooLong reject-and-revert path, the
REAL three-row-template listbox confirmed against the installed EoR dat
before writing any page code, and Summary's own independent gmCG3DView
preview instance wired through a second ChargenPreviewController pair
mirroring the Appearance page's exact composition shape).
Ports CharGenState::RandomizeCharacter and its six sub-primitives into
RuntimeCharacterCreationState — not approximated: the RandInt/RollDice
semantics are independently confirmed from both the decompiled RNG bodies
and the CharGenStateVtbl union struct in acclient.h. Three consumers:
the chargen screen's open-roll (retiring AP-214's honest-blank deviation
and reproducing the Appearance page's gender-flip-on-init quirk), the
Summary page's Random button (behind the retail randomize-warning
confirm), and the Appearance page's Random button (narrowing AP-212 to
just Heritage/Profession/Town's still-approximated rolls and Skills'
still-unported RandomizeSkills).
Wires the Finish button (previously ghosted) with retail's NoName/
CreditWarning dialog pair, adds the F12 amendment's HeritageOrGenderUnset
local refusal to TryBeginFinish (register AP-223) as a defensive backstop
now that the screen-open roll normally makes it unreachable, and wires
the four ID_Character_Err_* rejection dialogs for the 0xF643 response
codes CC3 already parsed but nothing displayed.
Register: TS-82 retired, AP-214 retired, AP-212 narrowed, AP-223/224/225
filed (heritage/gender Finish refusal, Summary's two-bucket skill-list
narrowing, the 32-vs-33 name-length threshold reconciliation).
Runtime 1722/0 (was 1713), App 5240/3 skips (was 5223/3), Headless 166/0
unchanged, full solution Release build green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
R1: LivePresentationComposition's chargen-preview diagnostic fired on every
ordinary launch without ACDREAM_RETAIL_UI set, since interaction.RetainedUi
is null in that configuration and there's no Appearance page to warn about.
Narrowed the else-if guard to require RetainedUi is not null too, so the
diagnostic only fires in the one configuration it actually diagnoses.
R2: filed AP-221 for the F8 one-shot-binding disposition the re-reviewer
accepted as scoped but which shipped without its own register row — the
chargen preview's GPU-side binding reads the retryable mount coordinator's
widget exactly once, so a slow-DAT frame permanently kills the preview for
the session with only R1's diagnostic as evidence.
R3: rewrote AP-217 after re-deriving from the decomp. The original row
claimed the GradCircle was an interactive click-to-hue picker with no
handler wired up. gmCGAppearancePage::ListenToElementMessage's dispatch
switch has no case for the GradCircle's offset at all — it isn't a click
target in retail either. DoGradDisk is a paint-only routine that blits the
gradient art tinted with the current color (or blanks it for Eyes)
whenever SetColor/SetSelection run. acdream's real gap is that it never
repaints the GradCircle — a cosmetic paint gap, not a dead control.
N1: tightened AP-220's "leaving Gearknight for something else" — the
decomp shows leaving Gearknight for Olthoi/OlthoiAcid takes a separate
branch that does not randomize; only leaving for a non-Olthoi heritage
does.
N2: added the requested media pin to the F2 spin-highlight live-DAT test,
then measured it against the installed EoR dat rather than assuming it
would pass. It doesn't: none of the nine spins author Highlight-state
media on either consumed arrow face segment, so
TrySetRetailState(Highlight) is a silent no-op for all of them today.
Pinned the test to the measured reality (ActiveState stays "Normal") and
filed AP-222 documenting the discovery — unresolved whether retail's own
spin art has the same gap.
Plan doc: CC6b-MOUNT ledger row updated to REVIEW-CLOSED with the full
commit chain and re-review disposition; OWED list corrected for AP-217/
AP-222.
Gates: dotnet build -c Release green. App suite 5223 passed / 3 skipped
(ACDREAM_PROBE_LIVE_MOUNT=1, ACDREAM_DAT_DIR set) — count held exactly at
baseline. Runtime suite 1713/0 — unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fixes every finding from the dual-lens review of 34c6fceab0 (architectural
PASS-with-items, retail-fidelity FAIL). Re-derived every decomp citation
against docs/research/named-retail/acclient_2013_pseudo_c.txt directly
rather than trusting the reviewer's transcription.
Wrap/normalize semantics (F1): CycleIndex's decrement-from-Unset landed on
0; the decomp's shared decrement tail (label_47f065/label_47f6d9, the same
switch the headgear ring was ported from) computes new=cur-1=-2 on the raw
signed int32, which wraps to count-1 — matching headgear's own ring shape.
Also ports the spin body-click normalize-and-write-back retail's cases
0xa5-0xae all share (NormalizeChoiceOnSelect), which acdream had dropped
entirely. Flips the one test that pinned the wrong expectation and adds
select-zone coverage no prior test isolated.
Heritage gate (F3): Update's Gearknight/Olthoi/OlthoiAcid branches reset
SetChoice(FACE)/SetSelection(HAIR) unconditionally, not only when Clothes
was showing — a conditional gate stranded Nose/Mouth as the current part
under a Face-tab session.
Doc corrections propagated everywhere they repeated (F4, F5, F6, plus the
plan doc's own CC6b-MOUNT ledger row for F1/F3): the gmBarberUI heading
citation conflated PostInit with InitializePage; Random's Appearance
disable was mislabeled a placeholder when it's really AP-212's unported
RandomizeAppearance/RandomizeClothing gap; the master-page doc still called
the Appearance page content-inert after this campaign made it real.
Visual substitutions widened (F2): AP-215 named only two of the Appearance
page's swatch/spin substitutions. Ports the two cheap ones directly —
current-part highlight via SetSelection's SetState(1)/SetState(6), routed
through the existing UiButtonStateMachine.Normal/Highlight ids and
IUiDatStateful.TrySetRetailState seam (installed-DAT-confirmed
ToggleBehavior=true on all nine spins); the shade scrollbar's SetVisible(0)
for Eyes vs acdream's Enabled=false. Files the other five (DoColorSpots,
the inert GradCircle, spin-caption/heritage-caption loss, the Skin-spin
MoveTo reposition, the Gearknight-boundary randomize calls) as new register
rows AP-216..AP-220 and corrects the plan doc's false claim that AP-215
already named the GradCircle.
Unlocked DAT read (F7, BLOCKER): ChargenPreviewController.Rebuild called
ChargenAppearanceFactory.TryCompose outside _datLock while the very next
line correctly locked TryBuildAnimated — CC6a's own F4 class of bug,
reintroduced at this catalog's first production call site. Wrapped in the
same lock; documented the invariant on ChargenAppearanceCatalog itself.
One-shot preview mount (F8): LivePresentationComposition reads
ChargenPreviewViewportWidget once, but its underlying mount
(CharacterCreationUiMountCoordinator) is explicitly retryable while this
GPU-resource composition pass is not — unlike PaperdollViewportWidget,
which IS eager/non-retryable, so the "mirrors Paperdoll" doc claim was
false. Retrofitting cross-frame retry here would mean restructuring this
composition's one-shot contract for every private viewport (paperdoll,
creature appraisal) and FrameRootComposition's fixed frame-group array —
out of this round's blast radius. Corrected the doc and made the failure
loud (a diagnostic log) instead of silent.
Dispose leak (F9): ChargenPreviewController.Dispose left the preview
WorldEntity referenced by the leased renderer until the renderer's own,
later disposal. Releases it on its own teardown now.
Test-quality items (F10, F11, F13): pinned the spin arrow widths
(47px, both arrows) the 174 zone boundary is derived from, plus a
controller test for the previously-uncovered select zone. Measured the
shade scrollbar's authored orientation instead of assuming it — it is
VERTICAL (33x85) — which is a real production bug: UiScrollbar only routed
scalar-mode mouse events when Horizontal was true, so the shade control
never fired in production. Added OnVerticalScalarEvent/DrawVerticalScalar
mirroring the existing horizontal scalar path. Converted
ChargenPreviewControllerTests from silent-pass [Fact] to the shared
InstalledDatFactAttribute skip-reporting pattern.
Adjudication (F12): AD-101's retirement leaves TryBeginFinish's four local
refusals (NoName/AttributeCreditsUnspent/AlreadyPending/RosterFull) with no
heritage/gender gate — currently latent since Finish stays hard-disabled
this round. Amended the campaign plan's CC5 slice scope to require BOTH a
heritage/gender refusal AND a real RandomizeCharacter port before the
connected user gate opens Finish; noted the interaction on AP-214's own
register row. No CC5 implementation in this commit.
Gates: dotnet build -c Release green across the full solution. App suite
(Release, ACDREAM_PROBE_LIVE_MOUNT=1) 5223/3 skips, Runtime suite
1713/0 — both clean across repeated runs. A full-solution run surfaced
three pre-existing, previously-documented flakes unrelated to this change
(Streaming.LandblockBuildFactoryTests/LandblockPresentationPipelineTests
#402, Core.Net.Tests.NakEmissionTests loss soak) — each confirmed passing
in isolation, consistent with their known full-suite-parallelism-timing
history; none touch any file this commit changes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The page-mount half CC6b-PRE deferred: CharacterCreationAppearancePage
(gender buttons, Face/Clothes sub-tabs, nine spin controls with retail's
decrement/increment/select-as-current-part OnClickAt zones, nine color
swatches, shade scrollbar, zoom/rotate wiring) plus ChargenPreviewController,
which bridges the ChargenPreviewRenderer/ChargenPreviewZoomController
camera-injection gap CC6a/CC6b-PRE left open and mounts as the third private
creature viewport beside paperdoll/creature-appraisal.
Color-wheel scouting (campaign risk item 4): live-DAT probe found every
color-wheel-family id resolves through existing DatWidgetFactory mappings
(Button/Scrollbar/generic fallback) — no new widget type needed.
The @140355 gender-flip-on-init oddity (risk item 5): resolved via decomp
alone — gmCharGenMainUI's own ctor calls CharGenState::RandomizeCharacter
before any page constructs, so retail's chargen screen is never actually
blank on open; the Appearance page's gender-flip code always fires against
a real, randomly-rolled gender. Filed AP-214 (acdream doesn't port
RandomizeCharacter this round, so it opens honestly blank instead) and
AP-215 (two narrow visual substitutions: swatch .Selected highlight vs
retail's separate overlay, ordinal labels vs retail's icon-only spins).
AD-101 retired: the Heritage page's auto-gender-select interim default is
deleted now that the Appearance page's real gender buttons exist. TS-82
narrowed to Summary-only.
Scope addendum: ChargenPreviewRotationController's parameterless-constructor
default changes from 0f to a new RetailDefaultHeadingDegrees=180f constant
(retail's InitializePage override, not the ctor's raw 0) — every real
gmCG3DView owner converges on 180 before its first frame, so a controller
defaulting to 0 was a trap for future consumers.
Runtime 1713/0, Core 4786/1 skip, Content 147/0, App 5220/3 skips (Release,
ACDREAM_PROBE_LIVE_MOUNT=1) — zero failures across two clean full-solution
runs; the one Core.Net.Tests NakEmissionTests flake observed on a third run
is the same pre-existing, previously-documented timing flake (zero files
under src/AcDream.Core.Net/ touched, passes 100% in isolation).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CC6a (index->ObjDesc factory + static-pose offscreen renderer) and
CC6b-PRE (idle loop, rotation, zoom, alternate-setup plumbing) both
closed through dual-lens review -> fix round -> narrow re-review. The
branch carries its own cross-branch renumbering (TS-84, ISSUES #403) so
this merge is number-clean against the CC4 rows.
Notable review outcomes carried in: the barber refutation (chargen has
NO alternate-setup checkbox — all five write sites are gmBarberUI), the
idle-by-default finding with its corrected InitializePage evidence, and
the 180-degree initial heading owed to the mount half.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
# Conflicts:
# docs/ISSUES.md
# docs/architecture/retail-divergence-register.md
R1: the unsound elided-ctor-byte argument survived at its canonical
citation site (ChargenPreviewEntityBuilder's class doc, which the two
corrected docs point at) and in the ledger row's Deliverables column,
which contradicted its own review-status column. Both now carry the real
evidence: InitializePage @0x0047FDD0 writes an explicit m_bZoomedIn = 0
at 0x004802C3.
R2: the verified 180-degree initial heading (m_fCurHeading = 180f at
0x00480235 + SetPlayerHeading at 0x0048023F, cross-confirmed at
gmBarberUI::PostInit and the summary page) now has a durable home in the
CC6b-mount OWED list — without it the mount half ships a character
facing away from the camera.
Merge prep: the branch-local TS-82 renumbered to TS-84 (the CC4 branch
independently allocated TS-82 and landed first) and the branch-local
ISSUES #402 renumbered to #403 (same collision, same rule), with the
Core doc reference updated.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
F1 (BLOCKING, doc-only) — the idle-by-default rationale rested on an unsound
"uninitialized C++ member defaults to 0" argument (heap operator-new memory
is indeterminate, not zero). Verified and replaced with the real evidence:
gmCGAppearancePage::InitializePage @0x0047FDD0 writes an EXPLICIT
this->m_bZoomedIn = 0; at 0x004802C3, immediately after that same function
points the camera at the zoomed-IN per-heritage eye (0x00480286-0x0048029E).
Fixed in all three places: the register's TS-83 retirement clause,
ChargenPreviewAnimator's class doc, ChargenPreviewZoomController.IsZoomedIn's
doc. Recorded the retail quirk this implies: the character starts framed
close-up while not-zoomed-in, so the first Zoom In click (once mounted)
tweens close-eye->close-eye (visually null) while still freezing the
animation — the port reproduces this faithfully.
F2 — ChargenPreviewZoomController and ChargenPreviewAnimator kept
independent _zoomedIn bools synced only via a nullable animator parameter,
risking desync. Retail's m_bZoomedIn is a single field gating both camera
and animation, so the fix makes the animator the sole state owner:
ChargenPreviewZoomController now takes its ChargenPreviewAnimator as a
required constructor dependency, IsZoomedIn reads straight through to it,
and ZoomIn/ZoomOut no longer take a parameter at all — there is no second
bool left to disagree.
F3 — documented the DoRotation counter-clockwise branch's x87-stack
decompiler artifact (BN renders x87_r7_1 = x87_r6_3 at 0x0047CAEB, which
would store delta-degrees instead of the timestamp for CCW only); the port
already stores "now" in both branches, cited against
feedback_bn_decomp_field_names.md.
F4 — ChargenPreviewAnimator.ApplyIdleFrame now double-buffers two
List<MeshRef> instead of allocating fresh every 30fps tick.
F5 — filed docs/ISSUES.md #402 tracking the RetailAnimationCyclePlayback /
LiveEntityAnimationPresenter duplication as an owned post-CC follow-up,
referenced from the new type's own doc.
F6 — reworded the ChargenPreviewEntityBuilder.TryBuild "byte-identical"
claim to result-identical (TryBuildAnimated now also resolves the idle DID
and loads the idle Animation before the wrapper discards them).
F7 — added the missing clockwise >360 clamp test (readable decomp
polarity, unlike F3's CCW artifact).
ALSO — rewrote the CC6b ledger row's m_alternateSetupID MUST-COVER note per
the reviewer's F11 concession: all five write sites belong to gmBarberUI
(the post-creation barber shop), not gmCGAppearancePage, which has no
option-checkbox-equivalent field at all. Added the enclosing-function
citations and an explicit directive that CC6b-mount must NOT build a
crown/no-flame checkbox on the Appearance page.
Tests: ChargenPreviewRotationControllerTests +1 (10 total),
ChargenPreviewZoomControllerTests +2 and every case rewritten for the
required-animator constructor (9 total). Core.Tests 4786/1 skip (unchanged),
Content.Tests 147/0, App.Tests 5152/6 skips (+3) — zero failures in
isolation, full solution Release build green. Two pre-existing flakes
observed across repeated full-solution runs, neither caused by this round
and neither reproducing standalone: Core.Net.Tests' NakEmissionTests loss
soak, and Content.Tests' DecodedTextureCacheTests concurrency race.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The final CC4 re-review closed R1-R4 (the fixed-canvas arbiter, headless
create-gate proof, #402 filing) and left one residual: the arbiter's
mismatch-throw makes 'both char-select screens author 800x600' a crash
premise on the exact user-gate path (ACDREAM_OPEN_CHARGEN=1 ->
char-management declares -> chargen declares on top), and only
char-management's extent was DAT-pinned. The chargen live-DAT probe now
pins the root at 800x600 the same way — measured against the installed
DAT (passes 7/7), not inferred. Ledger row flipped to REVIEW-CLOSED with
real shas.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
EOF
Idle animation loop: decomp re-read of gmCGAppearancePage::Update's trailing
StartAnimation/StopAnimation gate (~0x0047EF01-0x0047EF12) plus the ctor
evidence that m_bZoomedIn is a decompiler-elided bool (never explicitly set
away from its zero default, unlike its two sibling bools) establishes that
retail's chargen preview defaults to the idle loop PLAYING, not the frozen
rest pose CC6a shipped as a deliberate simplification (TS-83) — the rest pose
only appears once Zoom In fires. New Core primitive
RetailAnimationCyclePlayback ports CPhysicsObj::set_sequence_animation's
advance-with-wrap + lerp/slerp effect (the same algorithm
LiveEntityAnimationPresenter's legacy NPC-idle branch already carries inline;
not consolidated this round — out of blast radius for a preview-only
feature, noted in the new type's own doc). New ChargenPreviewAnimator drives
the per-tick swap; ChargenPreviewEntityBuilder gained TryBuildAnimated
alongside the byte-behavior-unchanged TryBuild. Olthoi/OlthoiAcid use the
SAME enum key for idle and rest DIDs (decomp-confirmed quirk). TS-83 retired
in the register (§4 count 50->49).
Rotation controller: ChargenPreviewRotationController ports
Rotate/DoRotation (0x0047CB50/0x0047CA80) verbatim — toggle-to-stop,
deltaDegrees = ((now-last)/RotationSecondsPerRevolution)*360, single-pass
+-360 clamp (not a full modulo, matching retail's own tail), the -1.0
invalidation sentinel. Applies to the entity's heading via the existing
MoveToMath.SetHeading port, not the camera, confirming CC6a's own note.
Zoom tween: ChargenPreviewZoomController ports ZoomIn/ZoomOut/
DoZoomAnimation (0x0047CF00/0x0047D050/0x0047C960) — a LINEAR 0.6s tween
(no easing curve in the decomp) between the already-recorded camera eye
profiles, calling into the animator's zoom swap IMMEDIATELY at button-press
time, matching retail's call order exactly.
m_alternateSetupID (research correction): re-reading the decomp
function-by-function found all five m_alternateSetupID write sites —
including the two the CC6a review cited — belong to gmBarberUI (the
post-creation barber shop), not gmCGAppearancePage, which has no
m_pOption1Checkbox-equivalent field and never writes the field. For
character creation the field is always INVALID_DID in retail. TryCompose
still gained a real, decomp-cited alternateSetupIdOverride parameter
(default no-op) implementing gmCG3DView::Update's generic override
precedence, for a future non-chargen consumer.
RetailHeldPose extraction: shared ResolvePoseDid/ComposePartTransform
between RetailPaperdollPoseApplicator and ChargenPreviewEntityBuilder — a
clean mechanical extraction, behavior-identical on the paperdoll side.
Bookkeeping: CC6a ledger row now cites its real commit SHAs (55bfd9ca,
1774d8b2); new CC6b-PRE ledger row records scope done + the page-mount half
still owed.
Tests: RetailAnimationCyclePlaybackTests (10, Core), ChargenAppearanceFactoryTests
(+4), ChargenPreviewRotationControllerTests (9), ChargenPreviewZoomControllerTests
(7), ChargenPreviewAnimatorTests (7, hand-built fixtures), ChargenPreviewEntityBuilderTests
(+5, installed-DAT). Core.Tests 4786/1 skip, Content.Tests 147/0, App.Tests
5149/6 skips — zero failures, full solution Release build green. One
pre-existing, unrelated flake noted: Core.Net.Tests' NakEmissionTests loss
soak failed once in the full-suite run, passed 1/1 isolated.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CC4 re-review returned NOT CLOSED: R1 (MEDIUM, blocking) is a new residual
the F1 fix itself introduced, plus three LOW riders (R2, R3, R4).
R1 — nulling UiRoot.FixedCanvasSize on chargen Close() stripped it from
character-management, which stays active underneath and only sets the
canvas on its own activation edge. Root cause (reviewer-named): two
controllers writing one host-global with no owner. Fixed with the
root-cause shape (reviewer's option (c)): UiRoot.DeclareFixedCanvas(owner,
size)/RevokeFixedCanvas(owner), an owner-scoped arbiter — every declarer
must agree on the canvas size (a mismatch throws instead of silently
last-writer-wins), and the canvas nulls only once EVERY declarer has
revoked. Both CharacterCreationUiController and CharacterManagementUi-
Controller now declare/revoke instead of writing FixedCanvasSize directly;
grepped for stragglers, none remain in production code (the raw setter
stays public only for UiRootFixedCanvasTests' isolated scale-math
coverage). New test (reviewer-specified):
CharacterScreensFixedCanvasArbiterTests — two controllers sharing one
UiRoot, proving the canvas stays set through chargen's Exit-confirm Close
while char-management is still active, nulling only once char-management
also deactivates, plus the original F1 defect's own covering case (both
revoke together at world entry).
R3 — HeadlessSessionHostTests.ContentLease_InstallsRealChargenOptions_
SelectHeritageIsAccepted proves F6's install actually opens the gate: a
content lease carrying a real hand-built DatCharGen heritage (not
ChargenOptions.Empty) is installed, and TrySelectHeritage for it succeeds.
R2 — filed docs/ISSUES.md #402 for the pre-existing
Streaming.LandblockBuildFactoryTests.Build_UsesTheSuppliedSharedReaderGate
full-suite flake (unrelated to Campaign CC).
R4 — fixed "unchached" -> "uncached" typo in
InteractionRetainedUiComposition.cs.
Runtime 1713/0, App 5127/13 skips (+2), Headless 166/0 (+1), full solution
Release build green. Live-DAT probes 7/7 under ACDREAM_PROBE_LIVE_MOUNT=1.
The known #402 flake did not fire across 3 consecutive full-suite runs
this session.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Dual-lens review of CC4 (0e71d3b8) returned architectural FAIL (F1, F6)
and retail-fidelity PASS-with-reservations (F2, F3, F4), plus LOW
findings F5, F7-F12. F13 (TS-82's merge collision with campaign-cc6a) is
merge mechanics for the orchestrator, not addressed here.
F1 (HIGH, blocking): CharacterCreationUiController never released
UiRoot.FixedCanvasSize, on a FALSE premise that CharacterManagementUi-
Controller does a per-tick set (it does not — it sets once on activation
and nulls on Deactivate/Dispose). Root cause: RuntimeCharacterCreation-
State had no CompleteEnter() analogue to RuntimeCharacterSelectionState's,
so the creation view reported IsActive=true for an entire in-world
session. Added CompleteEnter(), wired at both LiveSessionController
in-world edges (StartCore, EnterHighlightedCore); made Open/Close/
Deactivate/Dispose set/null the canvas symmetrically; corrected the false
comment and ledger claim; added FixedCanvasSize test coverage.
F2 (MEDIUM-HIGH, blocking): the attribute-slider scalar mapping was not
retail's. Fixed display to value/100f (UpdateAttributeValues @
0x0048251d) and the drag inverse to truncate+clamp-low-only, no rescale
(ListenToElementMessage @ 0x004829c0, independently re-verified against
the decomp). Added tests at scalar 0.5/0.0 plus a display-direction test.
F3 (MEDIUM, blocking): ported the unported heritage-button tab-restore
arm (ListenToElementMessage @ 0x004e9450) — SHOW/HIDE id sets independently
re-derived from the decomp, including the genuine Lugian (0x100005f1)
no-restore quirk, reproduced faithfully. Wired via a new HeritagePage
click callback; added restore + quirk tests.
F4 (MEDIUM): ported SetTown's (@ 0x0047c360) separate per-town page-root
state literal (Holtburg->0x10000034 etc.), independently re-derived from
the decomp's tail-merged branches; wired via the existing
IUiDatStateful.TrySetRetailState seam; added a test.
F5 (MEDIUM): softened AD-103's unmeasured pixel-equivalence claim.
F6 (MEDIUM, blocking): DECISION — install ChargenOptions in the headless
content path (chosen over marking headless creation out-of-scope).
HeadlessSessionHost now calls InstallOptions off the shared content
lease's Dats, beside the existing InstallSpellMetadata call.
F7: AP-213 already named the label format and click/double-click
substitution explicitly on inspection — no edit needed.
F8: AP-212 now names all six DoRandom primitives with a known landing site.
F9: AD-101 retirement corrected to precede CC5's Finish un-ghosting.
F10: merged ItemAppraisalTextFormatter's duplicate <summary> block.
F11: fixed TS-82's wrong AP-211 cross-reference.
F12: cached the chargen DatStringResolver once per composition instead of
per ResolveText call.
Runtime 1713/0, App 5125/13 skips (+8 new tests), Headless 165/0, full
solution Release build green. Live-DAT probes 7/7 under
ACDREAM_PROBE_LIVE_MOUNT=1.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Addresses the CC6a dual-lens review (architectural PASS with reservations,
retail fidelity PASS with reservations, merge after F1/F2/F3).
F1 (BLOCKING) - AlternateSetup/setupId tested the wrong sentinel (0)
instead of retail's INVALID_DID (0xFFFFFFFF, CharGenState::GetSetupID
@0x005C5B22). A hair style storing that value would have been adopted as
a literal Setup id, nulling Get<Setup> and killing the whole preview.
Fixed both sites with a new InvalidDid constant; added two hand-built
tests plus an installed-DAT sweep of every hair style across all 26
heritage/gender combinations (869 selections, zero unresolved Setup ids).
F2 (BLOCKING) - TS-82's register row, ChargenClothingTable.cs's doc, and
the plan's ledger row all understated Undead's measured clothing-coverage
gap as "headgear/trousers/footwear" (3 slots) with a self-contradicting
"4 of 4 non-shirt slots" aside. Corrected everywhere to the true measured
ALL FOUR slots (headgear, trousers, shirt, footwear).
F3 (BLOCKING) - the palette-math "three independent sources" claim
overcounted: ACViewer's ClothingTableList.xaml.cs:97 computes a different
expression for a different problem, and its vendored PaletteSet.cs is
ACE's own file, not an independent implementation. Rewrote the evidence
paragraph in ChargenPalSetMath.cs to the two sources that actually hold
(decomp control flow + ACE's "Taken from acclient.c" port).
F4 (MEDIUM) - ChargenPreviewEntityBuilder.TryBuild did unlocked dat reads;
DatCollection is not thread-safe and every sibling dat-touching resolver
in this layer takes a shared datLock. Added a required datLock parameter;
every dat read now happens inside one lock, mirroring
RetailPaperdollPoseApplicator.Apply's shape.
F5 (LOW) - noted the pre-existing Streaming.LandblockBuildFactoryTests
timing flake in the ledger so a future session doesn't chase it.
F6 (LOW) - fixed ChargenPreviewCamera.cs's rotation doc, which cited a
nonexistent identifier in a dimensionally-wrong expression; corrected to
retail's actual DoRotation @0x0047CAC7 per-tick formula.
F7 (LOW-MEDIUM) - the TS-82 measurement was WriteLine-only; pinned with
real assertions (zero gaps for the 9 standard heritages, exactly the 4
measured Undead table ids on both genders). Kept the existing env-gated
skip pattern (confirmed house convention).
F8 (LOW) - the inner PalSet-miss loop recorded-and-continued past a miss;
retail's own loop returns immediately on a miss (~0x005A7B32), aborting
every remaining choice in that garment. Changed continue to break; added
a test proving a subsequent present PalSet is correctly not applied.
F9 (LOW) - fixed three dangling <see cref="...Compose"/> doc references
(the method is TryCompose).
F10 (LOW) - the packed (byte)(range/8) narrowing was unchecked; a real
NumColors of 2048 happened to wrap to the correct "whole palette" 0
sentinel by unchecked-cast accident. Replaced with explicit PackOffset/
PackNumColors helpers that document the 2048->0 equivalence deliberately
and throw on any other unrepresentable shape.
F11/F12 (LOW, CC6b scope) - noted in the plan's CC6b row: the second
m_alternateSetupID override source is unmodelled, and a shared
RetailHeldPose helper is worth extracting before a fourth consumer.
Test counts: Core.Tests 4772/1 skip (+5), Content.Tests 147/0 (+1),
App.Tests 5121/6 skips (unchanged; F5's named flake did not reproduce) -
zero failures, full solution Release build green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Mounts gmCharGenMainUI (enum 0x10000039, root 0x100003CC) via
CharacterCreationUiController/CharacterCreationUiMountCoordinator,
cloning CharacterManagementUiController's recipe. Master shell ports
SetProgressState @0x004e7a10 (Olthoi tab-hide + redirect) and
ListenToElementMessage @0x004e9450 (Back/Next/Finish/Help/Exit/Random
nav) verbatim, with free tab navigation over all six pages. Heritage,
Profession, Skills, and Town pages bind to CC3's
RuntimeCharacterCreationState commands; Appearance and Summary mount as
content-inert placeholders for CC6b/CC5.
Live-DAT probing (CharacterCreationLiveDatTests) found two widget-
mapping surprises the decomp's DynamicCast hints don't predict: the
Profession slider's value field imports as an editable UiField (wired
for direct numeric entry), and the avail/health/stamina/mana/credits
displays author as UIElement_Button hosts whose Type-12 value child is
swallowed by UiButton.ConsumesDatChildren — substituted with the
button's own Label. No new DatWidgetFactory widget types were needed.
Threads the installed DAT's real ChargenOptions into Runtime via the
new RuntimeCharacterCreationState.InstallOptions, called from
ContentEffectsAudioCompositionPhase.Compose (mirrors
InstallSpellMetadata's pattern); headless keeps ChargenOptions.Empty
unchanged. Wires CC3's F14 status-hook gap (ApplyCharacterCreated/
ApplyCreationFailed) to SessionStatusWriter for both graphical and
headless hosts, and adds the CharacterCreation view/command seam
through CurrentGameRuntimeAdapter and DeferredGameRuntimeStateCommands
alongside CharacterSelection's existing shape.
Register: AD-101/102/103, AP-212/213, TS-82 filed for the auto-gender-
select interim default, the omitted ToD-account gate, the button-Label
widget substitution, the Random-button approximation, the flat-listbox
Skills simplification, and the Appearance/Summary placeholders.
Runtime 1713/0 (was 1707), App 5117/13 skips (was 5101/6), Headless
165/0 unaffected, full solution Release build green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Delivers the CC6a foundation half of the chargen 3D preview: the missing
index->ObjDesc appearance factory the campaign plan's acdream-seams
section named, plus a static-pose offscreen renderer following
PrivateEntityViewportRenderer's proven paperdoll/appraisal architecture.
Page mount, spin/color-wheel controls, and rotate/zoom behavior stay out
of scope per the CC4-parallel worktree contract (CC6b, after CC4 merges).
Core (src/AcDream.Core/CharGen/, pure, no Chorizite on public surfaces):
ChargenAppearanceFactory.TryCompose ports gmCG3DView::Update @0x004EE9D0's
ObjDesc rebuild in its exact decompiled order - base body, hair style,
clothing in retail's own Headgear/Trousers/Shirt/Footwear order (not the
UI tab order or the wire's field order, both of which differ), eyes
(bald-aware), nose, mouth, then the unconditional skin subpalette, hair
color, eye color. ChargenPalSetMath ports PalSet::GetPaletteID's
shade-to-index formula, cross-checked three ways (decomp control flow,
ACE's PaletteSet.GetPaletteID "Taken from acclient.c" citation, ACViewer's
identical slider math). ChargenPalSet/ChargenClothingTable are pure
projections behind IChargenPalSetSource/IChargenClothingTableSource so the
factory itself never touches a dat.
Content (src/AcDream.Content/CharGen/): ChargenAppearanceCatalog is the
cached dat-backed implementation of those two source interfaces, mirroring
ChargenTableReader's no-leak discipline.
App (src/AcDream.App/Rendering/): ChargenPreviewRenderer is a third facade
over PrivateEntityViewportRenderer beside PaperdollViewportRenderer and
CreatureAppraisalViewportRenderer - no existing rendering file touched.
ChargenPreviewCamera carries the four retail-verbatim per-heritage eye
profiles from gmCGAppearancePage::Update @0x0047E8F0 (cross-checked
against ZoomIn/ZoomOut's identical literals) plus the recovered rotation
(3.0 s/revolution) and zoom-tween (0.6 s, reconstructed from the
decompiler's garbled float literals - the plan's own "measure if it
matters" note is resolved, not garbled beyond recovery). Rotation applies
to the character model, not the camera, per gmCGAppearancePage::DoRotation.
ChargenPreviewEntityBuilder resolves Setup/GfxObj/Surface/Animation itself
(there is no live entity yet), reusing DatLiveEntityProjectionMaterializer's
surface-override algorithm and RetailPaperdollPoseApplicator's held-pose
technique, generalized to chargen's per-heritage rest-pose DID.
Two register rows filed: TS-83 (the plan-named CC6a static-pose-vs-retail-
idle-loop staging, CC6b to retire) and TS-82 (measured, not assumed - the
un-ported clothing Setup-substitution fallback chain costs nothing for the
9 standard heritages with clothing UI, but Undead's default gear choices
genuinely lack ClothingBaseEffects coverage for Undead's own body Setup).
Tests: ChargenPalSetMathTests, ChargenAppearanceFactoryTests (hand-built
fixtures), ChargenAppearanceCatalogInstalledDatTests (installed-DAT sweep,
all 26 heritage/gender combinations, zero missing PalSet/ClothingTable
ids), ChargenPreviewCameraTests, ChargenPreviewEntityBuilderTests
(installed-DAT-gated, proves a real 34-part Aluvian mesh resolves).
Core.Tests 4767/1 skip, Content.Tests 146/0, App.Tests 5121/6 skips - all
pre-existing skips, zero failures, full solution Release build green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The CC3 narrow re-review returned CLOSED (both lenses PASS, merge
recommended) with residual R1: the post-create wire-slot assignment read
the cached wire CharacterList count, which ACE never refreshes after a
create — correct for the first create, off by one for a second create in
the same session (reachable via create Ok -> server-rejected guid enter
-> ReturnToSelection -> create again), the same wire-contract failure
class F2 fixed. Root fix now rather than carried: a
creates-since-CharacterList counter (the equivalent of retail's own
CharacterSet growing via AddIdentity per create), reset on every fresh
wire CharacterList apply and at generation reset, applied only to the
cached-wire branch since the display-roster fallback already contains
prior appends. Regression test drives the full
create->Ok->rejected-enter->create-again flow and pins wire slots
0/1/2/3.
Docs: CC3 ledger row flipped to REVIEW-CLOSED with real shas (re-review
R2); CC7 risk item 8 downgraded to LATENT with measured installed-DAT
data (user-prompted): every heritage's single cost override is Arcane
Lore at NormalCost=0/PrimaryCost=2 vs global 4/6, so ACE's over-deduction
(= NormalCost = 0) cannot fire with end-of-retail data — the earlier
"may be rejected" claim was inferred from code without measuring.
Runtime 1707/0 Release.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Opus dual-lens review of CC3's RuntimeCharacterCreationState passed on
retail fidelity but failed the controller integration: the post-create
log-straight-in indexed the CACHED wire CharacterList, which ACE never
resends after a create (it only appends server-side and replies Ok) —
with zero pre-existing characters this throws, with N it can silently
enter the WRONG character. The same stale-index problem corrupted every
pre-existing character's delete slot on roster re-sort. Fixes all four
blocking findings plus a credit-gate correctness bug (retail warns and
lets the user confirm through unspent credits; it does not force a full
spend) and eight lower-severity findings from the same review round.
F1 (blocking): WorldSession gained a guid-based EnterWorld(uint,string,
TimeSpan?) overload sharing EnterWorldCore with the index-based one;
ILiveSessionOperations gained a default EnterWorldByGuid method.
LiveSessionController factored EnterSelectedCore/the new
EnterCreatedCharacterCore through a shared EnterHighlightedCore so the
post-create enter sends by the exact guid the 0xF643 Ok reply carried,
never by a roster index.
F2 (blocking): RuntimeCharacterSelectionState gained a real
AppendCreatedCharacter primitive that preserves every existing entry's
ActiveIndex (a wire contract — SendDeleteCharacter sends it as the
CharacterSet slot) and assigns the new entry's from the pre-create wire
roster count, instead of round-tripping the post-create roster through
ApplyRoster's name-sort-and-renumber.
F3 (blocking): retail's DoFinish(this, arg2) gate is
"arg2 != 0 && remainingAtrbCredits > 0" — the ordinary click warns and
refuses, but the warning dialog's own confirm re-invokes DoFinish(this,
0), which sends anyway with credits unspent (ACE accepts this).
TryBeginFinish/Finish gained a confirmedUnspentCredits parameter; the
plan doc's "retail FORCES full spend" line is corrected in the same
commit.
F4 (blocking): a stale out-of-range template index surviving a heritage
switch to a heritage with fewer templates now clears to TemplateUnset,
matching ConstrainAllByHeritage's clamp.
F5/F9/F10: three register-row/doc citation corrections (AP-207's real
FitTemplateToCharacter call sites — a fourth one the original filing
also missed; the Slot field's real retail assignment source; AP-209's
classID branch table for Olthoi/OlthoiAcid). F6: ApplyCreationResponse
no longer publishes from inside the owner lock. F7: two new tests pin
BalanceAttributes' persistent donor cursor (successive-overspend
advance, Self-to-Strength wrap). F8: ResetSkillLevels' doc corrected to
retail's real both-costs->=0 gate. F11: the integration test fixture
captures guid-based enter calls and uses two pre-existing characters
whose wire order differs from alphabetical order, so the roster
assertion actually exercises F2 instead of coinciding with it by
accident. F12: filed register row AP-211 for the client-side RosterFull
slot-cap refusal (no retail DoFinish-layer counterpart). F13: narrowed
Finish's bare catch to InvalidOperationException/SocketException and
bound _scope to a local. F15: RandomizeStartAreaLocked leaves the start
area unchanged on an empty list instead of forcing -1, matching retail.
Runtime 1706/0 (was 1701), Core.Net unchanged at 994/0, full solution
Release build green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Ports retail's CharGenState as the one Runtime-owned character-creation
state machine, mirroring RuntimeCharacterSelectionState's exact pattern
(snapshot/delta/event-stream, borrow-only view, generation-gated
commands, one mutable owner, no App types). Every command ports a named
retail function: SetHeritageGroup, SetGender, SetTemplate/ApplyTemplate
(Custom = template 0, Olthoi force-lock), the six attribute setters plus
GetAbsRemainingCredits/BalanceAttributes (retail's literal round-robin
order and fairness cursor), SetSkillLevel plus ResetSkillLevels' free-skill
baseline (reusing CC1's ChargenSkillCreditMath two-tier cost lookup
verbatim), RandomizeStartArea, and DoFinish's complete gate sequence
(empty name / unspent attribute credits / already-Pending / client-side
roster-vs-slotCount cap).
LiveSessionController gained a sibling IRuntimeCharacterCreationCommands
implementation, a CreateCharacter wire hook, and a response handler that
reuses existing machinery rather than inventing new paths: the Ok
identity is appended to the roster via RuntimeCharacterSelectionState's
own ApplyRoster, and the "log straight in" behavior reuses the private
EnterSelectedCore. ILiveSessionLifecycleHost gained two default-no-op
hooks (ApplyCharacterCreated/ApplyCreationFailed) so AcDream.App needs
zero changes to keep compiling; wiring them to the status stream is a
CC4 follow-up.
Filed four divergence-register rows for the corners deliberately not
ported: the FPU-unrecoverable FitTemplateToCharacter auto-detect (AP-207,
ACE only reads the field for title text), the per-style color-count
approximation (AP-208, CC1's model has no per-style palette data), the
classID DAT-DID placeholder (AP-209, ACE ignores the field), and
ApplyTemplate's atomic-vs-sequential attribute apply (AP-210).
34 new tests: full state-machine coverage (every Finish gate, every
rejection-code mapping, duplicate-NameInUse tolerance, Olthoi lock,
attribute balance/lock interaction, uncostable-skill rejection) plus a
LiveSessionController integration suite proving the wire send is exactly
55 skill slots (decoded from a real WorldSession + GameMessageCapture)
and the full Ok/rejection round trip through WorldSession.ProcessDatagram.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Both narrow re-reviews returned CLOSED. This closeout takes the two cheap
re-review residuals before CC3 takes references to the shared model:
R2: every array handed into the typed chargen model is now wrapped in
Array.AsReadOnly at the projection seam — a T[] behind IReadOnlyList<T>
was still downcast-mutable, and ChargenOptions is a process-shared
singleton graph.
R3: the no-Chorizite-leak guard now also walks public fields; every
current type uses properties, but a public field would have slipped
through the property-only walk.
Ledger: CC1 fix-round sha corrected to cb4703e8 (the cell previously
cited the pre-amend 459a87f2), CC1/CC2 rows flipped to REVIEW-CLOSED
with the re-review outcomes, R1 (retail refunds +1 credit on a
both-tier cost miss; port charges 0 — unreachable via retail's own
listbox, noted for CC3) and the Olthoi-locked-to-template-0 decomp fact
recorded for CC3/CC4.
Core.Tests 4736/1 skip, Content.Tests 145/0, Release.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Implements all six Opus review findings against 04450041 (Campaign CC
CC1 chargen data layer):
- F1 (HIGH, blocking): ChargenTemplate's doc claimed "Custom" has no
ChargenTemplate entry and cited two nonexistent addresses. Verified
against the named retail decomp: gmCGProfessionPage::UpdateProfession
@ 0x004821b0 resolves BOTH the highlighted button and the description
string from CharGenState.template_ 0..6, and case 0 is button
0x100003d9 / ID_CharGen_CustomText. Custom IS template index 0 (the
"Adventurer" row CC1 already found sitting at the attribute floor).
CharGenState::SetTemplate @ 0x005C5A60 confirms every button (including
Custom) calls CharGenState::ApplyTemplate @ 0x005C5080 when committing,
so selecting Custom resets the sliders/skills to that row rather than
leaving them untouched.
- F2 (MEDIUM): retail's skill-cost lookup is two-tiered
(ACCharGenData::GetSkillTrainedCost/GetSkillSpecializedCost @
0x005C26D0/0x005C27D0 fall through to the global SkillTable,
portal.dat 0x0E000004, on a heritage-list miss — confirmed against
ACE's identical PlayerFactory.cs precedence). ChargenTableReader now
also projects the global SkillTable into
ChargenOptions.GlobalSkillCostsBySkillId, and
ChargenSkillCreditMath.ComputeSpent/RemainingCredits check the
heritage list first and the global list on a miss. Added an
installed-DAT completeness assertion recording reality: the global
table prices 38/54 advancement skill ids, every one of the 13
installed heritages ships exactly one heritage-specific override
(always also priced globally), and 16 ids are genuinely uncostable in
both tiers. Also filed a CC7 risk-item note: ACE's own heritage-
override branch over-deducts on Specialize (PlayerFactory.cs:184-211)
— a retail-legal build may be rejected by local ACE at the CC7
connected gate; that is an ACE bug, not an acdream defect.
- F3 (MEDIUM): every collection ChargenTableReader hands into the
record model is now frozen at projection (ToFrozenDictionary/ToArray,
matching MagicCatalog's house pattern), including both
ChargenOptions.Empty dictionaries.
- F4 (LOW): added a reflection guard test
(ChargenNoChoriziteLeakTests) that walks every public
AcDream.Core.CharGen member (property/indexer/constructor/method
types, recursively through generic arguments) and fails if any
resolves to the DatReaderWriter or a Chorizite* assembly.
- F5 (LOW): ChargenGenderOptions.HasAnyAppearanceOptions's doc now
states precisely what the installed-DAT gate proves (an OR across
eight lists, for at least one gender per heritage) rather than the
stronger claim it previously made, and explicitly calls out the three
omitted color lists. Added a second installed-DAT gate that records
per-list reality across every gender of every heritage — found
complete, no empty lists anywhere in the installed DAT today.
- F6 (LOW): ChargenOptions.TryGetHeritage/TryGetStarterArea now use
[MaybeNullWhen(false)] instead of null! suppression, matching the
house pattern already used elsewhere in the test suite. Fixed every
call site this surfaced (more than the five originally estimated,
since Content.Tests has TreatWarningsAsErrors).
Core.Tests: 4737 passed / 1 skip (pre-existing, unrelated).
Content.Tests: 145 passed / 0 skip.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
F1 (MEDIUM): the correlation-latch docs claimed replies are never
misattributed; in truth an overlapping send OVERWRITES the latch and the
first reply routes to the newest request's event. Narrowed all three doc
sites to the exact contract (single outstanding request; overlap refusal
is CC3's Runtime verification gate, retail's DoFinish UNDEF-state rule)
and pinned the overwrite behavior with
OverlappingSend_OverwritesTheLatch_ReplyRoutesToNewestRequest.
F2 (LOW): filed register AD-100 for the drop-unless-armed deviation —
retail's Handle_CharGenVerificationResponse@0x0055E8B0 has no armed gate
and processes whatever arrives against its persistent verification state.
F3 (LOW): doc note in CharacterCreate.cs — ACE double-sends NameInUse
(IsCharacterNameAvailable runs twice; the first callback's return exits
only the lambda), so the second reply hitting the drop path during a
connected gate is EXPECTED, not a defect.
F4 (LOW): creationFailed's enum-member key renamed name -> reason and the
ATTEMPTED character name added as name, before any consumer shipped —
one status vocabulary must not give the same key two meanings
(characterCreated.name is a character name). Contract, writer, tailer,
and shape-pinning tests updated in lockstep.
F5 (LOW): the thread-id probe-note pointer now cites
ProbeNetLogOutbound's doc comment, where the note actually lives.
Fidelity fold (reviewer's positive note): the latch is retail's OWN
discriminator one layer down — 0x0055E8B0 case 1 branches on
GetVerificationState()==PENDING (create) vs not (restore) — now cited in
both the latch doc and CharGenVerificationResponse.cs.
Core.Net 994, Runtime 1667, Launcher.Core 324, all green Release.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds the CC1 data layer for Campaign CC (retail character creation):
a reader for portal.dat's CharGen table (0x0E000002) plus a
presentation-free, Chorizite-free typed options model, and the pure
attribute/skill credit math the later CC3 Runtime owner needs.
Retail oracle (docs/research/named-retail/acclient_2013_pseudo_c.txt):
- ACCharGenData::Serialize @ 0x005C36D0 (table shape: StartingAreas +
HeritageGroups)
- HeritageGroup_CG::Serialize @ 0x005C2100
- Sex_CG::Serialize @ 0x005C1600
- Template_CG::Serialize @ 0x005C0450
- CharGenState::SetHeritageGroup @ 0x005C67A0 and the six attribute-slider
setters (~0x005C46CE..0x005C494E): remainingAtrbCredits = totalAtrbCredits
- (str+end+coord+quick+focus+self) — a heritage's AttributeCredits is the
budget the six RAW attribute values must fit, not points above the floor.
- CharGenState::Reset @ 0x005C68A0: atrbMin=10, atrbMax=100.
- gmCharGenMainUI::DoFinish @ 0x004E9170: Finish refuses only when
remainingAtrbCredits > 0 (attributes only — skill credits are never
gated to zero, confirmed by reading the function body).
- CharGenState::UpdateRemainingSkillCredits @ 0x005C37C0: exactly one of
NormalCost/PrimaryCost is charged per Trained/Specialized skill.
- gmCGAppearancePage::Update @ 0x0047E8F0: the mHeritageGroup==0xc/0xd
(Olthoi/OlthoiAcid) camera-offset branch CC6 will need.
Cross-checked against ACE's ACE.DatLoader.FileTypes.CharGen and
ACE.DatLoader.Entity.HeritageGroupCG/SexCG/TemplateCG/SkillCG loaders
(same field order, different byte format) and ACE.Entity.Enum.HeritageGroup
/ SkillAdvancementClass for the two small stable enums the model exposes.
src/AcDream.Core/CharGen/: ChargenOptions (root: StarterAreas +
HeritagesById), ChargenHeritageOptions, ChargenGenderOptions (BaseObjDesc
+ every appearance-option list: hair styles/colors, eye colors, eye/nose/
mouth strips, headgear/shirt/pants/footwear, clothing colors),
ChargenTemplate, ChargenObjDesc (palette/subpalette/texture/anim-part-swap
shape, mirrors PaletteOverride's presentation-free pattern), and the pure
math: ChargenAttributeMath (RemainingCredits/IsFullySpent/range checks) and
ChargenSkillCreditMath (retail's Trained-xor-Specialized cost sum) plus
ChargenSkillAdvancementSet, a structurally-fixed 55-slot type (reserved
slot 0 + SkillId 1..54) so CC2's future wire builder cannot send anything
but exactly 55 entries.
src/AcDream.Content/CharGen/ChargenTableReader.cs projects the Chorizite
DBObj graph into the Core model (MagicCatalog.Load's shape) — no Chorizite
type crosses into ChargenOptions.
Tests: hand-built-fixture unit tests for the pure math (Core.Tests) and the
Content projector (Content.Tests), plus six installed-DAT gate tests
(ContentConformanceDats pattern) against the real portal.dat: 13 heritage
groups (11 standard + 2 Olthoi), the four named heritages with retail
display names incl. "Gharu'ndim", every heritage has a gender with
non-empty appearance option lists, every template's attributes stay in
10..100 and never exceed its heritage's budget (discovered live: NOT every
template fully spends it — each human heritage's "Adventurer" template
sits at the floor as retail's real-DAT-backed "Custom" starting point),
start-area indices resolve into the shared list, and skill costs key to
valid 1..54 wire ids.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Wire (Core.Net):
- CharacterCreate.cs: outbound 0xF656 builder, byte-exact port of
Proto_UI::SendCharGenResult@0x00546a70 -> ACCharGenResult::Pack@0x005c7570
-> CG_Pack@0x005c7200. Account String16L first (packed outside CG_Pack),
then the constant-1 u32, heritage/gender, 14 appearance strip/style/color
u32s, 6 f64 shades (skin/hair/headgear/shirt/trousers/footwear, retail
order), template, 6 attributes, slot, classId, numSkills + exactly 55
u32 skill-advancement classes (ReadOnlySpan validated ==55, throws
ArgumentException otherwise — ACE terminates the session on any other
count via PlayerFactory.CreateResult.ClientServerSkillsMismatch), name
String16L, startArea, isAdmin, isEnvoy, and a trailing checksum whose
exact 19-term accumulation set (heritage+gender+3 strips+hairColor+
eyeColor+hairStyle+headgearStyle+shirtStyle+trousersStyle+footwearStyle+
template+6 attributes) is read byte-for-byte off CG_Pack's decompiled
accumulator (0x005c7213-0x005c74c3) — headgearColor/shirtColor/
trousersColor/footwearColor/shades/slot/classId are deliberately absent
from the sum despite sitting adjacent on the wire. Cross-checked against
ACE's CharacterCreateInfo.Unpack/Appearance.Unpack and holtburger's
CharacterCreateRequestData (types.rs:236-369), which agree on every
field and order. Retail routes via SendToLogon — the same queue
CharacterDelete already uses.
- CharGenVerificationResponse.cs (new): promotes the shared 0xF643 parse
out of CharacterRestore — full Code enum (Undef..AdminPrivilegeDenied=7,
ACE's CharacterGenerationVerificationResponse) plus the conditional
Ok-only identity payload (guid/String16L name/u32 secondsGreyedOut).
CharacterRestore.Parse now delegates to it; CharacterRestore's public
Parsed shape, Parse signature, and every existing test expectation are
UNCHANGED.
- PacketWriter.WriteDouble: f64 little-endian helper for the shade fields.
WorldSession dispatch (Core.Net):
- Added an awaiting-request latch (None/Restore/Create), armed by
SendRestoreCharacter/the new SendCharacterCreation immediately before
each send (SendCharacterCreation builds the body first so a skill-count
throw never arms the latch for a request that was never sent), cleared
the instant a matching 0xF643 is dispatched (success OR parse failure —
a malformed reply must never wedge the latch open) and on Dispose.
0xF643 now routes to CharacterRestoreReceived or the new
CharacterCreateResponseReceived (Action<CharGenVerificationResponse.Parsed>)
by that latch; an unexpected 0xF643 with nothing outstanding logs once
and is dropped, never misattributed. Fixed
WorldSessionCharacterSelectionTests' restore-dispatch test, which
previously fed a bare CharacterRestore response with no preceding
SendRestoreCharacter — that shape is now the "no outstanding request"
drop path by design.
Status events (Runtime + Launcher.Core, contract first):
- Amended docs/plans/2026-08-14-launcher-campaign.md §LA1's pinned status
vocabulary to add characterCreated{guid,name} (Ok reply identity, named
to mirror CharGenVerificationResponse's own fields and to read distinct
from enteredWorld — retail logs a freshly created character straight in
without a fresh characterList) and creationFailed{code,name} (raw Code
value + its enum member name).
- SessionStatusWriter.CharacterCreated/CreationFailed implement that
contract.
- Launcher.Core: CharacterCreatedStatusEvent/CreationFailedStatusEvent +
StatusEventParser cases, in lockstep.
Tests: CharacterCreateTests (byte-exact layout incl. checksum term-set,
55-slot fixture, wrong-count throws), CharGenVerificationResponseTests
(every Code value), WorldSessionCharacterCreationTests (create-then-
response routes correctly, restore unaffected, no-outstanding drop,
second-response-after-consumed drop, Dispose clears the latch, a builder
throw never arms it), SessionStatusWriterTests + Launcher.Core
StatusEventParserTests/StatusFileTailerTests (pinned shape + tailer
round-trip) for the two new events.
Verified: dotnet build AcDream.slnx -c Release — 0 errors. Full solution
test run green (Core.Net.Tests 993/993, Runtime.Tests 1667/1667,
Launcher.Core.Tests 323/323, plus every other project in the solution).
WSL Ubuntu: Core.Net.Tests 993/993, Runtime.Tests 1667/1667.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Recon-grounded (three parallel sweeps 2026-08-15): full retail flow
(gmCharGenMainUI mode 0x1000000b, six ECGProgress pages with every widget
id), byte-exact 0xF656 layout incl. the trailing checksum ACE never reads,
0xF643 create semantics (local roster append + retail logs straight in; no
fresh CharacterList), the chargen DAT table shape, the preview rig
(gmCG3DView over CreatureMode - Appearance/Summary pages only), and every
acdream seam to reuse (generic layout resolver, fixed canvas, offscreen
viewport pipeline, RuntimeCharacterSelectionState as the J-owner template,
the 0xF643 correlation landmine). Slice order CC1 data / CC2 wire (parallel)
then CC3 Runtime owner, CC4/CC5 form pages, CC6a/b appearance+preview
staged, CC7 end-to-end + user gate.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
F1 (MUST-FIX): RetailWaitDialogView was the ONE dialog view the 0a7dc7d6
EffectiveCanvasSize sweep missed - the Entering World wait dialog (fires
on ENTER, the char screen primary action) still centered against the raw
window and landed off the visible canvas. Same three-line fix as its three
siblings; the enter-wait test now grows the window over the fixed canvas
and asserts canvas-space centering.
F3/F4: two stale assertions about the DELETED first AD-98 substitution
(the register section-2 header line and the live-DAT oracle test doc) now
describe the completed FixedCanvasSize mechanism - the C4-closeout failure
mode, caught before it cost anything.
F5: RetailDialogData.Confirmation sets ElementAttribute40 itself (retail
MakeConfirmExitDialog writes 0x8E=1, 0xAC=1, 0xC5); the manual set in
GameplayConfirmationController is gone.
F6: MapWindowToCanvas truncates instead of rounding - rounding mapped the
window far edge one past the canvas last valid coordinate, a 1px dead
hit-test band; test updated to truncation semantics + far-edge case.
F7: AD-98 records that the no-letterbox aspect claim has no decomp
citation and is confirmed by the user live gate pass 2026-08-15.
F8: the durable world-name read in StartCore is IsCurrent-gated like every
neighbouring step.
F2 filed as #401 (invert RetailUi to opt-out - product-default decision,
not a gate fix).
App 5100+6 skips, Runtime 1666, green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two live-integration gaps the ef96c554 unit tests could not see:
1. World box stayed empty against ACE: ServerName (0xF7E1) arrives in the
SAME connect batch as CharacterList, so ServerNameReceived fires during
the handshake pump BEFORE the controller binding subscribes - the
event-only wiring proved the state and controller but never the live
ordering. StartCore now reads the durable WorldSession.ServerInfo after
connect exactly like the roster (ILiveSessionOperations.GetServerInfo,
default interface method so no fake breaks); the event remains for
post-connect updates. Pinned by a Start-level test.
2. The exit confirmation rendered far right of the screen: all three
retail dialog views centered against the raw window size while the
active screen lays out in the fixed 800x600 canvas - center-of-1920
is canvas-760, which the stretch pushes off-center. Views now center
against UiRoot.EffectiveCanvasSize (canvas while a pre-world screen is
active, window otherwise). Pinned by growing the window over the fixed
canvas in the exit-dialog test and asserting the scrim spans the canvas
with the popup centered at 400.
Runtime 1666, App 5100+6 skips, green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The ef96c554 batch ghosted Credits with a comment claiming it was filed
as #397 - that number is the Windows graceful-stop issue and no Credits
entry existed. #400 now records the gap properly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Finding 1 (Exit button dead): retail's gmCharacterManagementUI Exit
button (element 0x100003A4, offset 7 from the listbox base in
ListenToElementMessage@0x004ed5a0) opens MakeConfirmExitDialog
(0x004ed250), whose exact ID_CharacterManagement_ConfirmExit text
(table 0x23000002) and m_confirmExitDialogContext re-entry guard are
now ported. On confirm (matching RecvNotice_CloseDialog@0x004ed760
case 1's ConfirmationResult check) the client exits through the
EXISTING graceful window-close seam (CharacterSelectionRuntimeBindings
.RequestExit -> d.Window.Close, the same delegate
GameplayInputCommandController's Escape fallback already uses) so
disconnected/exited status events still fire via GameWindow.OnClosing
-> CompleteShutdown. Retail's real post-confirm destination is
QueueUIMode(0x10000009) -> gmEpilogueUI, an epilogue screen this round
does not port — recorded as AD-99. Credits (element 0x100003A3,
QueueUIMode(0x10000005) -> gmCreditsUI) stays visibly ghosted like
Create, same treatment, out of scope this round.
Finding 2 (row names center-aligned, retail is left): the character
row template (LayoutDesc 0x21000004, element 0x100003A5, live-DAT
confirmed HJustify=Left with three stateful Type-3 highlight-art
children and no Type-12 caption child) authors its OWN justify
directly, with no separate text child to lift a label from.
DatWidgetFactory.BuildButton's Left-justify branch required
!ReferenceEquals(labelInfo, info) — true only when a label was LIFTED
from a distinct child — so a button's own direct HJustify=Left was
silently dropped to UiButton's Center default. Widened the branch to
also honor the direct case, preserving the existing lifted-child
LabelOffsetX behavior and leaving genuinely-centered buttons
(CREATE/ENTER/DELETE/RESTORE) untouched.
Finding 3 (World box empty): parsed ACE's GameMessageServerName
(opcode 0xF7E1, ACE.Server/Network/GameMessages/Messages/
GameMessageServerName.cs; retail CM_Login::DispatchUI_WorldInfo
@0x006ad860 -> ClientUISystem::Handle_Login__WorldInfo@0x005641a0 ->
ECM_Login::SendNotice_WorldName@0x00692b10, notice 0x186a2, consumed
by gmCharacterManagementUI::UpdateWorldName@0x004ec120 /
RecvNotice_WorldName@0x004ec360 onto element 0x1000039B) as
src/AcDream.Core.Net/Messages/ServerName.cs, cross-checked against
holtburger's ServerNameData. WorldSession.ServerNameReceived fires
alongside CharacterListReceived (ACE sends both in one
SendConnectResponse batch); RuntimeCharacterSelectionState.
ApplyWorldName is the new J-owner field (ungated by lifecycle, since
either message can arrive first); CharacterManagementUiController
binds it onto the WorldTextElementId UiText. Per the LA1 status
vocabulary, the characterList STATUS event's worldName field is
intentionally NOT added this round (kept bounded to the client-side
fix) — a follow-up if the launcher UI wants it.
Also corrects AD-44, discovered stale while filing AD-99: its opening
claim ("acdream has no retained character-management screen") was
false as of this session — LA7/LA8 shipped the screen in earlier
commits without updating this row.
Tests: exit-confirm open/cancel/confirm/re-entry-guard flow;
DatWidgetFactory own-HJustify-Left/Center regression tests plus the
live-DAT pinned row-justify assertion; ServerName parse round-trip
(byte-exact vs ACE's AceWireWriter fixture, truncation/wrong-opcode
cases); WorldSession dispatch test (roster+world in one wire batch);
RuntimeCharacterSelectionState.ApplyWorldName tests (order-independent
of ApplyRoster, unchanged-value no-op, Reset clears); controller test
binding the World text element to the live snapshot. Extended the
shared RetailDialogFactoryTests.BuildDialogLayout test fixture with a
Confirmation-type branch (Accept/Reject buttons) since this is its
first RetailDialogType.Confirmation consumer.
Suites: full solution Release build green; AcDream.App.Tests 5100/6
skips, AcDream.Core.Net.Tests 965/0, AcDream.Runtime.Tests 1665/0, all
Release, 0 failures; live-DAT probes (ACDREAM_PROBE_LIVE_MOUNT=1)
green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
AD-98's fixed-canvas stretch (73041d70) scales every retained-UI quad at
TextRenderer.AppendQuad, but the live gate reported it JAGGED — text
especially. Cause: dat-font glyph atlases and IconComposer's composited
icons upload nearest (TextureCache.UploadUiTexture's UiNearestRepeat
sampler) — correct at the native 1:1 scale (pixel-exact retail art), but
aliased once magnified 2.4x1.8. Chrome/background art was already fine:
it uploads through GpuSamplerDescription.WorldRepeat (linear) by default.
Retail's own fixed-canvas presentation is a single bilinear-filtered
frame blit, never a per-texture stretch — this closes that gap one step
earlier, at the source texture, without adding RHI surface area.
- TextureCache.GetOrCreateLinearUiTwin: lazily registers a SECOND table
slot for a nearest handle's IGpuTexture, sampled WorldRepeat (linear)
instead of nearest — no re-decode, no re-upload, no extra memory-ledger
bytes. Returns the handle unchanged for anything never registered
nearest (chrome, UiTextureTableHandle.None), so it's a cheap
unconditional probe. Twin slots are released in Dispose without
double-disposing the shared texture.
- TextRenderer.LinearTwinResolver + the DrawSprite chokepoint: swaps a
sprite's texture handle through the resolver only while
CanvasScale != One. At CanvasScale == One the resolver is never even
called — zero overhead on the ordinary in-world/UI path.
- InteractionRetainedUiComposition wires the resolver to TextureCache
right after every UiHost acquisition (the lease can hand back a host
from a prior session against a fresh TextureCache).
- AD-98's register row gets one added sentence recording the fix.
Tests: TextRendererLinearTwinTests pins the renderer-side handle-swap
seam GPU-free (segment handle selection); TextureCacheLinearTwinTests
pins twin creation/reuse/dispose against RecordingGpuDevice. App suite
5097/3 skips (Release, ACDREAM_PROBE_LIVE_MOUNT=1 live-DAT probes
included). Full solution builds clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Third iteration on the screen, completing AD-98. The previous substitution
stretched only the root BACKGROUND while the child widgets stayed at their
authored 800x600 pixel positions - and the background painting carries
visual anchors (the World/Characters captions are art), so the user gate
showed captions overlapping the listbox and every widget misaligned
against the stretched art.
Retail model (established at 71bf24fb): fixed-canvas pre-world screens
render at authored 800x600 and the whole composed frame stretches once at
presentation; the blitter has no stretch mode. Our equivalent now does the
same one stage earlier:
- UiRoot.FixedCanvasSize: while the char-select screen is active, the
retained tree lays out in its authored canvas and Draw scopes a uniform
scale onto TextRenderer.CanvasScale; the mouse entry points apply the
exact inverse so MouseX/MouseY and every hit test live in canvas space.
- TextRenderer.AppendQuad is the single emission chokepoint - sprites,
rects, AND glyphs scale together, including retail-authentic non-uniform
aspect distortion and stretched text. World-space HUD stays native (the
scale resets outside UiRoot.Draw).
- CharacterManagementUiController stops resizing Root to the viewport;
activate/deactivate/dispose set and clear the host canvas.
- UiDatElement returns to retail-pure copy-or-tile; the interim
StretchOwnBackgroundToFill flag is deleted.
- AD-98 updated to describe the completed substitution.
Tests: canvas-scale quad math, inverse input mapping (window click lands
on the canvas-space widget), degenerate-size guards, controller keeps
authored extent + sets/clears the canvas. App suite 5085/6 skips; live-DAT
char-select probes 3/3.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The LA8 char-select root (0x1000039A) authors LeftEdge=TopEdge=RightEdge=
BottomEdge=0 ("no anchor") in the installed DAT — confirmed via the new
CharacterManagementLiveDatTests.RootAuthorsNoEdgeAnchors_RetailNeverResizesItSelf
gate — so retail's own UIElement::UpdateForParentSizeChange (0x00462640) never
resizes this element; it stays a fixed 800x600 rect in retail's own tree.
Retail's generic UI sprite blit, Graphic::Draw (0x00693b20) dispatching to
Graphic::PutImage (0x00693a30) for an exact/undersized destination or a
modulo-wrapped tile loop otherwise, has no third "stretch" mode — confirmed
against BlitMode (acclient.h ~3135) and MD_Data_Image::m_drawMode/DrawModeType,
both COLOR-blend selectors, not tile-vs-stretch geometry modes. The prior
"Normal -> tile, matching ImgTex::TileCSI" citation in UiDatElement was a
mis-attribution: ImgTex::TileCSI (0x0053e740) is called exclusively from
TexMerge::CopyAndTile/ImgTex::CopyCSI for LAND-SURFACE terrain texture
compositing, never from the UI element system.
Given the dat authors zero resize anchors and the blitter can only copy or
tile, the only way retail's whole pre-world scene (background + buttons +
listbox together) fills an arbitrary window resolution is that these
fixed-canvas "flow" screens render at 800x600 and the WHOLE FRAME is
stretched once at presentation — outside the UI sprite system entirely.
acdream has no offscreen fixed-resolution UI render target / present-time
scale pass; CharacterManagementUiController's constructor instead resizes
the MOUNTED ROOT element itself to the live viewport, which is why its own
background tiled (Width/tw > 1 at any resolution above 800x600, wrapped by
GL_REPEAT).
Fix: UiDatElement gains StretchOwnBackgroundToFill (default false, every
ordinary chrome/container element keeps tiling) — when set, the element's
own DirectState background draws as one UV-0..1 quad instead of the native
tile formula. CharacterManagementUiController sets it on Root right where
Root is resized to the host viewport, reaching the same visual result as
retail's present-time stretch (no tiling, no aspect-preserving letterbox)
through a different mechanism. Divergence register row AD-98 records the
substitution.
Tests: three new UiDatElementTests pin the UV-span mechanism generically
(tile past 1.0 when unset and rect exceeds native size; clamped to 1.0 when
set; byte-identical to the old tile formula when rect equals native size,
so every unaffected panel is untouched). CharacterManagementUiControllerTests
pins Root.StretchOwnBackgroundToFill == true post-construction. The live-DAT
gate confirms the root's zero edge-anchors and Type=3 against the installed
DAT. AcDream.App.Tests: 5084 passed / 3 skipped with ACDREAM_PROBE_LIVE_MOUNT=1
(5081/6 skipped without it — the 3 live-DAT-gated tests skip).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Root cause: the LA8 character-select screen's root background RenderSurface
(0x06007576, LayoutDesc 0x21000004 element 0x1000039A) is PFID_CUSTOM_RAW_JPEG
— a complete JFIF byte stream (confirmed live: 414,230 bytes, FFD8...FFD9,
Width=0/Height=0 on disk) that SurfaceDecoder.DecodeRenderSurface had no case
for, so it fell through the switch's `_ => DecodedTexture.Magenta` default arm
with nothing logged. Retail's RenderSurface::CreateFromSourceData
(named-retail decomp @0x004440a0) hands this exact byte stream to the Intel
JPEG Library (`_ijlInit`/`_ijlRead`/`_ijlFree`) at runtime and reads the real
pixel dimensions from the JPEG's own SOF header rather than this
RenderSurface's Width/Height fields, which are legitimately 0 for this
format — the same reason the decoder's generic non-positive-Width/Height
guard was also wrong to apply here.
A per-id media sweep of the installed DAT (new EveryDeclaredMediaId_
ResolvesToADecodableTexture test) showed this was the ONLY unresolved id
among the screen's 25 distinct media ids — the listbox (0x1000039D) and every
button face resolve fine. The listbox interior and the ENTER button's
circular fill are both transparent regions layered on top of the root, so
the one broken root background bled through everywhere nothing opaque
covered it, producing all three symptoms (full-screen background, listbox
interior, ENTER circle) from one cause.
Fix: SurfaceDecoder now special-cases PFID_CUSTOM_RAW_JPEG before the
Width/Height guard and decodes it with StbImageSharp (dual Unlicense/MIT,
pure managed, no native dependency — works on the Linux headless/graphical
targets Slice K/L commit to). JPEG is ITU T.81-standardized, so any
conforming decoder reproduces the pixels IJL would; round-tripped a
synthetic fixture through the real decode path to confirm. Verified against
the live DAT: 0x06007576 now decodes to 800x600, exactly the screen's
LayoutDesc-authored size.
Guard: per claude-memory/feedback_ui_resolve_zero_magenta.md, an unresolved
id reaching the draw path should be loud. That memory's existing guard
("guard on the id, not the handle") only covers a DIFFERENT trap — a
zero/absent id — and could not have caught this one, which has a real,
non-zero, DAT-resolved id. No guard existed for "id resolves but can't
decode" or "id doesn't exist in either dat" before this change, so both were
silent. SurfaceDecoder now logs once per surface id on every magenta-return
path (null data, JPEG decode failure, unsupported format, no-palette
paletted format, decode exception); TextureCache.GetOrUploadRenderSurface
logs once per id when a RenderSurface isn't found in Portal or HighRes at
all.
Tests: CharacterManagementLiveDatTests.EveryDeclaredMediaId_
ResolvesToADecodableTexture (installed-DAT gate, ACDREAM_PROBE_LIVE_MOUNT=1)
sweeps every StateMedia id in the char-select root + listbox row template
and asserts none decode to the magenta placeholder — this class of gap now
fails the gate instead of shipping silently. SurfaceDecoderTests adds
PFID_CUSTOM_RAW_JPEG coverage (real decode via a synthetic from-scratch
JPEG fixture — not retail art, generated with StbImageWriteSharp and
round-tripped before being pasted in as a literal; corrupt-data and
null-SourceData magenta paths) plus PFID_P8/PFID_INDEX16 no-palette cases
that now flow through the same logged path.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A launcher-spawned client showed the world with NO interface at all -
character screen included. RetailUi rode ACDREAM_RETAIL_UI (the dev-era
opt-in), FromSessionConfig inherited the env parse, and the launcher
strips ACDREAM_* from children by design, so every product launch got
the dev default. A session-config launch IS a product launch: RetailUi
is now forced true on that path; the env flag remains the dev-launch
opt-in. Pinned by the session-config options test with a null env.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
F1: the crash reporter comment claimed the launcher never holds a password
in any field - false (ProfileEditorDialogViewModel, AccountProfile.Password,
StartRequest.Password). Reworded to the true, narrower invariant (no throw
site interpolates a credential VALUE into an exception message) and pinned
it with CrashReportNeverContainsAStoredPassword: a real STJ failure over a
profiles document containing a known password, corrupted after the
credential, must yield a crash file with the stack and without the value.
F2: the co-deploy Inputs covered only Bake own sources; a Content edit
never refreshed the 83 MB exe. Now the full reference closure. Fixing it
surfaced two more incrementality traps, both fixed and comment-documented:
SkipUnchangedFiles left the output older than the triggering input (target
re-ran forever - added an explicit Touch), and %(Item.Metadata) in a plain
Include does not batch (the literal percent-text became a permanently
out-of-date phantom input - globs are now spelled per project). Verified:
Core edit retriggers, then two consecutive clean incremental builds.
F3: RID publishes ran BOTH co-deploy paths (two self-contained bake
publishes). Build-time target now guarded on _IsPublishing; verified a
real win-x64 publish runs zero build-target co-deploys and still ships
both exes.
F4: comment misattributed PublishBakeTool=false to CI lanes; it is
target-local recursion guarding. F5: the x:Name reflection sweep now walks
the markup as XML and tolerates template-scoped names (no generated field
exists for those). F6: dead using removed. Hardening: the crash reporter
positional --data-dir fallback requires a fully-qualified path so a
relative or flag-shaped value cannot create ./crash-reports at an
arbitrary CWD.
Launcher 67/67, Launcher.Core 317/317.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Falsification-proven: 12/12 fail against AvaloniaXamlLoader.Load (the #398
crash shape), 12/12 pass against InitializeComponent. Launcher tests 66/66
on Windows and native Ubuntu, no display required. xunit -> xunit.v3 in the
launcher test project (required by Avalonia.Headless.XUnit 12.1.1 net10.0).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
#398 was a crash on every modal open/close caused by MainWindow's
constructor calling AvaloniaXamlLoader.Load(this) instead of the
generated InitializeComponent() — only InitializeComponent assigns the
x:Name backing fields, so every named control was null and the first
Dispatcher.UIThread.Post callback in OnViewModelPropertyChanged threw
NullReferenceException, killing the process. It reached the user gate
because no test in tests/AcDream.Launcher.Tests (ViewModel-only) ever
constructed a MainWindow. #399 is the process gap that let that class of
defect through 14,012 green tests.
Adds Avalonia.Headless.XUnit 12.1.1 to the launcher test project. Its
net10.0 dependency group targets xunit v3, so the project migrates
xunit 2.9.3 -> xunit.v3 3.2.2 (drop-in: all 54 pre-existing tests compile
and pass unchanged under dotnet test via xunit.runner.visualstudio 3.1.4,
which already supported v1/v2/v3; two call sites needed
TestContext.Current.CancellationToken per the new xUnit1051 analyzer).
TestAppBuilder.cs wires [assembly: AvaloniaTestApplication] to a headless
AppBuilder.Configure<App>() so the real App.axaml FluentTheme is live in
tests.
MainWindowViewTests.cs adds 12 [AvaloniaFact]/[AvaloniaTheory] tests:
- an explicit non-null + type check of every x:Name field the
code-behind dereferences (ProfilesTree, ServerNameTextBox,
AccountNameTextBox, CharacterNameTextBox, EditorSubmitButton,
FirstRunDatDirectoryTextBox, FirstRunCloseButton, UpdateCloseButton)
- a reflection sweep over every x:Name found in MainWindow.axaml, so a
future named control without a matching non-null field fails loudly
- one open+close round trip per ProfileEditorKind (all seven, including
Remove), plus the first-run wizard and the update prompt, each pumping
Dispatcher.UIThread.RunJobs() so the queued focus callback actually
executes instead of just being asserted vacuously
- a dedicated test for the _focusBeforeModal-restore branch (not just
the ProfilesTree.Focus() fallback), anchored on a real focusable
button since ProfilesTree (TreeView) has Focusable="False" under
FluentTheme — its own tab stops are TreeViewItem rows, so the
close-path assertions check "no exception escaped the dispatcher"
rather than "focus landed on ProfilesTree"
Falsification (required evidence): reverting MainWindow's constructor to
AvaloniaXamlLoader.Load(this) and rerunning gives 12 failed / 0 passed —
10 tests throw NullReferenceException at MainWindow.FocusActiveModal,
propagating cleanly out of Dispatcher.UIThread.RunJobs() (confirming
dispatcher exceptions are not silently swallowed), and the 2 reflection
tests fail on an explicit "x:Name 'ProfilesTree' was null after
construction" message. Restoring InitializeComponent() gives 12 passed /
0 failed. Full launcher suite: 66 passed / 0 failed, reproduced on both
Windows and native Ubuntu (WSL, no display/Xvfb — Avalonia.Headless needs
none). AcDream.Launcher.Core.Tests: 317/317 unaffected.
No CI workflow change needed: .github/workflows/headless-portability.yml's
portable-launcher job already runs dotnet test on the launcher test
project on both windows-latest and ubuntu-latest with no display setup,
which is sufficient for Avalonia.Headless.
Closes#399.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two gaps found launching the launcher for the LA11 gate.
1. acdream-bake was co-deployed only AfterTargets=Publish with a RID, so a
plain `dotnet build` left the launcher with no bake tool beside it while
App.OnFrameworkInitializationCompleted resolves it at
AppContext.BaseDirectory/acdream-bake[.exe]. A developer-built launcher
therefore reached the first-run wizard with an installer it could never
run. CoDeployBakeToolToBuildOutput does for Build what the publish target
does for Publish: still NO Launcher -> Bake project reference, still a
self-contained single file so exactly one file lands beside the launcher
rather than scattering Content/Chorizite assemblies into its output.
Staged through obj/ because publishing straight into the launcher output
makes the inner publish delete what the outer build just wrote.
Inputs/Outputs keep it incremental - verified: 79.6 MB bake exe present,
--help exits 0, and a second build skips the republish in ~1 s.
2. #398: the top-level guard printed only ex.Message, so the crash that
preceded this commit surfaced with no file, line, or frame. The full
exception now goes to a crash-reports file under the resolved data root
and stderr names the path. The first implementation wrote to the machine
real data root when option parsing itself failed, which broke LA11 process
local roots during an isolated run; the reporter now reads --data-dir
positionally for that fallback. Verified: report lands inside the isolated
root and the real root stays empty.
The redaction comment states exactly what is guaranteed - args/environment
are never serialized, while exception text may quote an option name or path,
which is safe only because credentials never enter launcher state.
Launcher.Core 317/317 green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
#399 (HIGH, process class): no test constructs MainWindow — the launcher
test project is ViewModel-only with no Avalonia headless package, which is
how a crash on every modal open/close passed 14,012 green tests and reached
the user gate. Fix direction is Avalonia.Headless.XUnit plus a view test
that drives every modal open/close, catching the class rather than one
spelling.
#398 (MODERATE): the top-level guard prints only ex.Message, so the fatal
NullReferenceException fixed at d54b8a78 surfaced with no file, line, or
frame; diagnosis needed a temporary code edit and rebuild. Fix direction is
a redaction-scanned crash file under the data root.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Every click in the launcher exited the process. MainWindow ctor called
AvaloniaXamlLoader.Load(this), which loads the XAML tree but never assigns
the generated x:Name backing fields, so ProfilesTree, ServerNameTextBox,
AccountNameTextBox, CharacterNameTextBox, EditorSubmitButton,
FirstRunDatDirectoryTextBox and UpdateCloseButton were all null. Opening or
closing any modal calls Focus() on one of them via
OnViewModelPropertyChanged, so the NullReferenceException escaped the
dispatcher and Program's top-level guard exited 74. A fresh isolated-root
start auto-opens the first-run wizard and hit the same line with no click
at all.
App.axaml.cs keeps AvaloniaXamlLoader.Load - that is the correct idiom for
Application.Initialize(), which has no named controls.
Verified live: the isolated-root launch that died instantly now stays up
with the first-run wizard open and the window responding.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Prove idle play remains passive and live until cancellation, then converges through one truthful status teardown. Keep probe mode string-only so numeric enum aliases cannot expand the pinned v1 contract, and record the Windows/WSL gates.
Both remaining agents were stopped for token budget and their partial work
committed as WIP (75a6724d LA1 fix round, c6019424 LA2). The handoff now
carries: full worktree paths with branches and HEADs, exactly what each
stopped agent had finished versus what it still owes, and a paste-ready
kickoff prompt naming all three resumable items plus the two owed merge
items.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Agent was stopped for token budget. Landed here: probe flag through
LiveSessionConnectOptions + the StartCore short-circuit, the mode field
with JsonRequired-to-semantic-validation move, host exit-code mapping,
and 34 passing tests including 3 new probe tests (agent last reported
green before the stop). NOT DONE: the idle-policy unit tests (next
step), full-suite verification, and the WSL run.
Build/test state UNVERIFIED at this commit. Next session: finish idle
policy tests, run Runtime+Headless Release suites Windows and WSL, then
dispatch the Opus dual-lens review.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Agent was stopped for token budget partway through the LA1 review fix
round. Landed here: F1 best-effort SessionStatusWriter, F2 App reader
tolerance (paths/mode), F5 argument-parsing hardening, plus new tests.
NOT DONE: F4 shared-fixture production shape (was the next step), F3
reconnect disconnected edge + recorded limitation, F6 exited
idempotency/reasons, F7 structural redaction test, F8 platform-guard
test + comment fix, optional RuntimeOptions PrintMembers redaction.
Build/test state UNVERIFIED at this commit. Next session: finish the
remaining findings, run the suites, then narrow re-review.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Self-contained handoff for a fresh session: what the campaign is and which
decisions are settled, the slice ledger with commits, the in-flight slices
and how to recover them from git, the two owed merge items (cross-assembly
contract test, Launcher.Core CI lane), the session landmines (index-sweep,
stale agent worktrees, contract-in-prompts), the binding process, and the
goal text to set.
Committed via pathspec so a live implementer agent index in this worktree
is untouched.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Opus review of LA3 returned FIX FIRST; this addresses every finding in
scope (F1-F5, F7-F12; F6 CI-lane addition excluded per instructions):
- F1 (CRITICAL): SessionProcessSettings.Paths is now nullable and left
null by SessionConfigComposer unless a caller supplies overrides, so
the JSON key is entirely absent instead of "paths":{} — the App-side
loader's strict UnmappedMemberHandling.Disallow would otherwise reject
every gui/guiSelect session-config document at load.
- F2: added SessionConfigComposer.ComposeProbe and a nullable
SessionDescriptor.Mode field ("probe", omitted for normal play) per
the pinned contract — no character/policy/plugins/loginCommands.
- F3: LauncherProcessSupervisor.Stop now tries
ILauncherChildProcess.TryRequestGracefulStop (Linux: libc SIGINT via
LibraryImport, K4-proven graceful headless logout) before
CloseMainWindow. Windows has no reliable no-window-console equivalent
today; filed docs/ISSUES.md #397 with the CREATE_NEW_PROCESS_GROUP +
CTRL_BREAK fix direction. Stop()'s blocking-timeout contract is now
documented for LA4.
- F4: LauncherProfileStore.Save chmods the Linux temp file to 0600
immediately after creation, before any credential is serialized;
failure paths and Load() clean up a stale .tmp.
- F5: added LauncherCoreDependencyBoundaryTests asserting Launcher.Core
references exactly AcDream.Platform and no packages.
- F7: StatusEventParser.Parse no longer throws on a whitespace/null
line; StatusFileTailer.ReadNewEvents swallows the File.Exists/open
TOCTOU window (FileNotFoundException/DirectoryNotFoundException/
IOException) instead of throwing.
- F8: Start() now kills (entire process tree) and disposes a child that
started successfully but failed while being fed its stdin password,
instead of orphaning it.
- F9: SetState is monotonic — once Exited, no later transition applies
or fires StateChanged, closing a Start()-path race where a
synchronously-exiting child could be "resurrected" to Running.
- F10: CharacterIdFormat.TryParse now requires the "0x" prefix (an
unprefixed hand-typed decimal id is also valid hex and was silently
misread); a parsed id of 0 is treated as unusable and falls back to
the name selector; LauncherProfileStore.MergeRoster normalizes both
sides through TryParse/ToHexString instead of raw string equality, so
a legacy unprefixed-hex row self-heals via name match instead of
duplicating.
- F11: StatusCharacterEntry.SecondsGreyedOut is now uint, matching
CharacterRosterEntry and the host writer.
- F12: added MalformedStatusEvent, returned for a recognized `e` whose
payload doesn't match its shape, distinguished from UnknownStatusEvent
(an unrecognized `e`).
AllowUnsafeBlocks was added to AcDream.Launcher.Core.csproj — required
by the LibraryImport source generator's function-pointer marshalling
stub for F3's Linux SIGINT P/Invoke.
Verification: dotnet build AcDream.slnx -c Release green (0 errors);
dotnet test tests/AcDream.Launcher.Core.Tests -c Release green at 94/94
on native Windows and under WSL (Ubuntu, verified across multiple runs
for the timing-sensitive SIGINT/sharing-violation tests, no flakes
observed).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The running LA1 implementer had pre-staged its work-in-progress; git
commit takes the whole index regardless of what git add named, so the
docs commit swept in 37 LA1 files mid-implementation. Content is intact;
LA1 completion commit(s) follow with the remainder, and the LA1 slice
review covers the combined range. Process rule adopted: no orchestrator
commits in a worktree while an implementer agent is live in it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The LA3 Opus review process note was right: the contract both sides
implement lived only in orchestrator prompts, which is exactly the drift
mode the pin exists to prevent (and it produced the paths-key CRITICAL).
The schema, field rules, probe-mode discriminator, and status vocabulary
are now a binding plan section; amendments change this text first,
implementations second. Ledger: LA3 fix round dispatched.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The spec seeded the wrong claim (restore extra strings = decompiler
artifact, no register row needed); the LA7a Opus review decoded the
PDB-paired binary and showed the two constant-string arguments are real,
making our guid-only request an adaptation — AD-97 filed on the LA7a
branch. Plan LA7 now carries the review-surfaced LA7b hazards (ACE
silent no-reply restore path, SendToLogon/SendToControl routing,
NumErrors sentinel).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Opus retail-lens review decoded the PDB-paired binary at
CPlayerSystem::RestoreCharacter@0x0055d760 and refuted the
uninitialized-edx justification: the two extra arguments are real
push imm32 of a constant PStringBase (BN mis-renders them, but they
pack to >=4 bytes each), so retail 0xF7D9 is >=16 bytes where ours
is 8. The guid-only CODE stands (ACE reads only the guid; holtburger
consensus) but it is an adaptation, not a corrected decompile — filed
as divergence register AD-97 and the doc comment now states the true
mechanism.
Also from the review: the 0xF643 conditional-parse doc now names BOTH
ACE flag-only failure branches (NameInUse + Corrupt); CharacterError
0x08 doc corrected (ACE misnames it ServerCrash2 — the port corrects
an ACE misnaming; ACE omits three values, not four); LA7b hazard notes
added (ACE silent no-reply on unknown restore guid; retail SendToLogon
vs SendToControl routing; NumErrors never rendered); two review-nit
tests (flag=0 Undef flag-only, non-Ok body with trailing bytes
ignored).
Core.Net suite: 953 passed / 0 failed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New AcDream.Launcher.Core (BCL-only, ProjectReference: AcDream.Platform
ONLY) plus tests/AcDream.Launcher.Core.Tests, both registered in
AcDream.slnx. This is the file-contract orchestrator core the Avalonia
launcher (LA4) will bind to — the game solution (Core/Runtime/App/
Headless) stays entirely out of this dependency graph, so the launcher
can never accidentally grow a game-protocol coupling.
- Profiles/: LauncherProfileStore owns launcher-profiles.json (spec §5
schema: version 1, servers[]/accounts[]/characters[]), strict
camelCase System.Text.Json (UnmappedMemberHandling.Disallow), typed
CRUD (add/edit/remove server; add/edit/remove account; edit character
settings), and MergeRoster (fold a reported roster into an account's
characters[] while preserving user-owned launchMode/plugins/
loginCommands, adding new rows with default guiSelect, and retaining
rows absent from the roster — they may be pending-delete). 0600 on
Linux via File.SetUnixFileMode after save.
- Launching/: SessionConfigComposer builds the pinned session-config
contract (Headless K1 shape + plugins/loginCommands/
loginCommandDelayMs/statusFile) from a profile character + install
record — character selector omitted entirely for guiSelect, policy
{id:"idle"} only for headless, credential always standardInput/
session. Passwords never enter this document (proven by a dedicated
test). LauncherProcessSupervisor spawns a host, feeds the password to
stdin then closes it, and exposes Starting/Running/Exited lifecycle;
Stop calls CloseMainWindow falling back to Kill after a timeout, both
reachable through an injectable ILauncherChildProcess/factory seam so
the state machine is unit-testable without real OS process timing.
- Status/: StatusEventParser decodes the v1 status.jsonl vocabulary
(started/connected/characterList/enteredWorld/pluginLoaded/
pluginFailed/disconnected/exited); an unrecognized "e" or a malformed
line degrades to a typed Unknown event rather than throwing.
StatusFileTailer incrementally reads new lines, tolerating a
not-yet-existing file and a partial trailing line (only advances its
read position past confirmed '\n' boundaries; a truncated tail is
simply re-read next poll, never parsed early).
- Integrity/: streaming SHA-256 + hex verify for later pak/download
checks (LA9/LA10).
Tests: 71 passed (profile CRUD + roster-merge matrix + strict-schema
rejection; composer golden-shape tests for gui/guiSelect/headless +
password-absence; supervisor tests against both an injected fake child
(state-machine determinism) and a real spawned `dotnet --version`
child (genuine cross-platform stdin/exit-code proof); tailer tests
incl. partial-line and not-yet-existing-file; SHA-256 tests). Verified
green on Windows (Release) and native WSL/Linux (Release) — the Linux
0600 test executes its real assertion body under WSL rather than
early-returning.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Ports the three character-management wire messages LA7 (design spec §7,
plan §11 item 4) identified as missing before the character-select
screen (LA8) can be built: delete, restore, and the server error channel.
Message types + tests only — no WorldSession/Runtime/UI wiring, that is
LA7b.
CharacterDelete (0xF655): outbound account+SLOT-INDEX request per
Proto_UI::SendDeleteCharacter@0x00546b30 (retail packs the account as
String16L then writes the trailing u32 directly after — NOT the character
guid; CPlayerSystem::DeleteCharacter@0x0055f830 resolves that slot via
CharacterSet::GetSlot before sending). The server's ack reuses the same
opcode with an empty body (ACE GameMessageCharacterDelete.cs); a fresh
CharacterList follows separately per CharacterHandler.cs:322 — that
refresh flow is explicitly out of scope here (LA7b).
CharacterRestore (0xF7D9 request / 0xF643 response): guid-only request,
per ACE (CharacterHandler.cs:331-385, ReadUInt32 only) and holtburger
(CharacterRestoreRequestData, guid-only) independent consensus. The
decompiled call site (Proto_UI::SendAdminRestoreCharacter@0x00546cf0)
appears to pack two extra strings, but its only caller
(CPlayerSystem::RestoreCharacter@0x0055d760) passes an uninitialized
local (`class PStringBase<char>* edx;`, never assigned) as the second
argument and `this` (a CPlayerSystem*, not a string) as the third —
textbook decompiler register-corruption, not real arguments. No
divergence-register row: this follows the correct reading of a corrupted
decompile, not a deviation from retail (spec §11 item 4). The response
reuses opcode 0xF643, a genuine retail collision with
CharacterCreateResponse (ACE's own comment: "This is a duplicate...",
GameMessageOpcode.cs:42); GameMessageCharacterRestore.cs always writes a
success shape (flag=1 + guid + name + secondsGreyedOut), but retail's
CharacterRestore handler can also reply via the CharacterCreateResponse
path on failure (e.g. NameInUse) with a flag-only body and no trailing
fields — the parser mirrors that conditionality instead of assuming the
four fields are always present.
CharacterError (0xF659): u32 error code, confirmed directly from retail's
inbound dispatcher UIQueueManager::ProcessNetBlobData@0x0055b000 ->
CPlayerSystem::Handle_CharacterError@0x0055d5d0, which reads
`enum charError` straight off the wire. The Code enum is a verbatim port
of retail's own enum charError (docs/research/named-retail/acclient.h:
4038-4067, 26 members incl. CHAR_ERROR_NUM_ERRORS) rather than a subset
filtered through ACE — retail's header names four members ACE's C#
CharacterError enum omits (LoggedOn, NoPremade, AccountInUse,
CharacterIsBooted) because ACE's server never sends them, though a
genuine retail server could. The 32-bit storage-width compiler sentinel
FORCE_charError_32_BIT is deliberately excluded (not a real value).
Unknown codes never throw — RawErrorCode always preserves the wire value.
Today acdream cannot surface any character-stage server error; this is
the first parser for the family.
46 new tests (byte-exact builder assertions, ACE-serializer-shaped
parser fixtures via the existing AceWireWriter test helper, all 26
retail error codes round-tripped, unknown/truncated/wrong-opcode
handling). Full Core.Net.Tests suite: 951 passed, 0 failed, 0 skipped.
Release build green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The re-review closed all six findings and flagged one docs-only nit: the
Platform layer block described App as reaching Platform transitively when
the same commit made the reference direct, and spoke of the launcher in
the present tense. Both corrected.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Opus dual-lens review of cb6502c8 passed with six findings; this lands
the fix round:
1. headless-portability.yml: AcDream.Platform src/tests join both path
triggers and the presentation-free build/test arrays — the moved XDG
tests run on ubuntu-latest again (they had fallen out of every Linux
lane).
2. acdream-architecture.md: AcDream.Platform gets its own layer block;
Runtime may-reference clause updated (the guard changed in cb6502c8,
its human-readable twin had not).
3. PlatformDependencyBoundaryTests: the BCL-only contract (zero
project/package references) is now enforced, not just observed.
4. memory/project_linux_graphical.md canonical seam renamed.
5. Plan LA0 recon corrected: the K0 Headless guard was never the guard
needing amendment (it asserts Headless own refs); Runtime own-refs
guard was — the commit did the right thing, the plan text now says so.
6. App declares its AcDream.Platform reference explicitly per its own
convention instead of riding transitivity.
Platform.Tests: 4 passed (3 moved + the new guard).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The launcher (LA3/LA4) needs the XDG/Windows path contract
(ApplicationPathSet/IApplicationPathEnvironment) without pulling in any
gameplay assembly. Move it out of AcDream.Runtime into a new BCL-only
AcDream.Platform project so the launcher-side Launcher.Core project can
reference it directly per the campaign plan (docs/plans/2026-08-14-launcher-campaign.md,
LA0). Namespace renamed AcDream.Runtime.Platform -> AcDream.Platform;
code is otherwise byte-identical (no logic changes).
AcDream.Runtime now carries a ProjectReference to AcDream.Platform and
re-exports it transitively, so App and Headless keep resolving the type
without a direct reference and K0's Headless single-ProjectReference
guard (HeadlessAssemblyReferencesOnlyTheRuntimeProject) stands unchanged.
The sibling Runtime dependency-boundary guard
(RuntimeProjectDeclaresOnlyApprovedProjectDependencies) does assert
Runtime's own project-reference set, so it needed a deliberate,
documented addition of AcDream.Platform to its expected list.
Moved tests/AcDream.Runtime.Tests/Platform/ApplicationPathSetTests.cs to
a new tests/AcDream.Platform.Tests/ project (namespace
AcDream.Platform.Tests) referencing only AcDream.Platform. Registered
both new projects in AcDream.slnx.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
User decision 2026-08-14: everything the launcher does ships Linux-tested
in this campaign (launcher UI, install/update with manual DAT picker,
headless launches with plugins + login commands, probe, per-slice Linux
test runs, Linux connected-gate section at LA11). GUI client launches
stay Windows-only until Slice L resumes later; the launcher renders GUI
modes disabled on Linux with an explicit note, and the host-agnostic
session-config contract means Slice L lights them up with no launcher
changes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Plan doc with twelve slices, dependencies, review protocol (Opus
dual-lens: architectural + retail-faithful), and ledger. Three parallel
recon reports grounded the slice bodies:
- Retail select screen is gmCharacterManagementUI: flat listbox +
Enter/Delete/Restore + dialogs. NO 3D preview (that machinery is
chargen-only gmCG3DView) — the spec 3D-preview slice is deleted, the
old retail-ui/05-panels.md pedestal claim is uncited and wrong.
Restore + CharacterError join scope; delete sends account+slot.
- Chat-command core (parser/router/catalog/ChatVM) is dependency-clean
BCL+Core; extraction to Runtime is a move, not a rewrite.
- Probe reuses the NoCharacters early-exit shape (graceful teardown at
the CharacterList stage exists today); roster plumbing is new.
- Bake tool needs --progress-json + explicit --out; no whole-file SHA
exists — launcher records/verifies its own.
- UI Studio is deleted (Campaign V) — stale references corrected.
Roadmap + CLAUDE.md Current state carry the campaign pointer.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Approved brainstorm outcome for the alpha launcher campaign: Approach A
file-contract orchestrator (session config in, stdin credential, JSONL
status events out), full in-UI CRUD for servers/accounts/credentials,
headless character-list probe, retail character-select screen (no
Create), plugins + login commands on both hosts, first-run DAT
locate/bake install, GitHub Releases update feed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ISSUES: #372/#374/#375/#378-#382/#385 flipped DONE (re-gate USER-PASSED
2026-08-14); #396 records the live-verified crash fix. CLAUDE.md Current
state: Campaign OP paragraph now carries the 2026-08-14 round and the
still-owed full OP3-OP6 sections + OP8 visual re-check.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The user's first click on a mapping button killed the client: the live
dialog catalog's wait root 0x31 carries retail class type 0x19 (WaitDialog),
which DatWidgetFactory left unmapped, so the root built as a plain
UiDatElement and RetailWaitDialogView's ctor threw out of UiButton.OnClick
into the render loop. The unit test missed it by standing the confirmation
fixture (type 0x13, mapped) in for the wait root — the structural-false-
negative class again. Pins: DatWidgetFactoryTests theory for both dialog
root types, plus an installed-DAT UiDialogRoot/0x3D/0x3E assertion in the
env-gated keyboard probe. OpenCaptureInstructions now converts a dialog
construction failure into its contracted 0-return (log + capture refused,
retail's own OpenMapWarnDialog failure shape) instead of crashing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three findings from the user's first Configure Keyboard look (OP8 gate,
2026-08-14), each root-caused against the named retail decomp:
- #394 row-caption font: the synthesized action-label UiText never set
DatFont and fell to the debug bitmap font. The authored row template
(0x21000009/0x1000002F, retail UIOption_ActionKeyMap) carries FontDid
0x4000000A (18px serif) — Bind now takes resolveTemplateFont and applies
the template's own authored font, resolved once per template pair.
- #395 key captions: raw enum spellings ("Shift+ShiftLeft") replaced by the
port of CInputManager_WIN32::GetNameFromKey @0x00687F40 /
GetNameFromKey_Internal @0x00687800 (RetailKeyNames): DAT string-table
override by DIK-name hash (key enum 4 -> 0x2300000A, meta enum 5 ->
0x2300000B, delimiter enum 3 -> 0x23000007 — GetDIDByEnum category 4,
live-probed), else the OS keyboard layout's own key name ("SKIFT") via
PlatformKeyNameProvider (Win32 GetKeyNameTextW — register row AD-96 for
the DirectInput-vs-GetKeyNameText adaptation), else the DIK-suffix
spelling. Bare modifier-key bindings show only the key name.
- #396 capture feedback: clicking a mapping button now opens retail's
instruction dialog (InitiateBinding @0x004899D0 -> OpenMapWarnDialog
@0x00488A00): a type-2 WAIT dialog on retail's MapWarn queue key
0x10000001 with ID_ActionKeyMap_MapInstructions (0x23000004, ACTION
variable interpolated), closed on key hit or ESC through the capture
callback; capture is not armed if the dialog cannot open, matching
retail. New RetailWaitDialogView (wait root 0x31 — same authored
popup/message pair 0x3D/0x3E as the confirmation root, live-DAT probed)
behind a shared IRetailDialogView presenter seam.
Probe evidence (env-gated, kept):
KeyboardConfigLiveMountProbeTests.ProbeKeyboardFontsAndKeyNameStrings.
Register: AD-96 filed. Gate script OP8 section updated (step 4 rewritten;
the "pressed/active state is enough" contract is retired).
Full Release solution suite green (13,424 passed / 4 skips).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The two-client user gate PASSED 2026-08-14 (open both ways, stage with
the retail trading marker, accept/decline, executed swap, Clear All,
cancel text). Every TEMPORARY [trade] probe line from gate rounds 1-2 is
stripped (ItemInteractionController, SelectionInteractionController,
SecureTradeUiController, LiveSessionCommandRouter, WorldSession,
RuntimeTradeState). Roadmap gains the shipped-trade ledger row.
Suites after strip: App 4,992/3, Runtime 1,626 - green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
staged-item trading marker
- Cancel text: ClientTradeSystem::Handle_Trade__Recv_CloseTrade
@0x0056DE30 shows "The trade has been cancelled." UNCONDITIONALLY
(every close reason) as 0x1A ClientLocal - the yellow top-center
SpewBox line. Wired at the router's onTradeClose beside ApplyClose;
the string lives in ClientTextRefusals with its citation.
- Staged-item marker: retail's mechanism decoded end-to-end - the
UIItem prototype (catalog 0x21000037) authors overlay child
0x10000438 (sprite 0x06001DAE, the green frame + corner trade icon),
bound @0x004E18FC and SetVisible(tradeState != 0) @0x004E2420;
gmSecureTradeUI::AddItem @0x004CA801 sets
ACCWeenieObject::SetTradeState(1) on YOUR staged items. Ported as:
UiItemSlot.ShowTradeOverlay + TradeOverlaySprite (drawn over the
icon), set on the trade window's self-grid cells; and
RuntimeTradeState now borrows the canonical object table and
maintains ClientObject.TradeState (1 at stage, 0 at remove/failure/
reset/close/clear) - which also brings the ALREADY-PORTED placement
policy's "You cannot move an item while it is being traded" refusal
to life (its input field previously had no live producer).
Runtime 1,626, App 4,992/3 - green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
root cause for every dead interaction) + retail's Total Items caption
The round-2 probes nailed it: the request seam fired for BOTH open
paths (use AND drag - "drag-release pick" -> "drag-on-player" ->
"request"), but no open-cmd, no wire-open, and no LiveCommandBus
drop-warning ever printed. MountSecureTrade captured
_bindings.Options.CommandBus() ONCE at mount time - the pre-session
surface whose Publish routes into a null route silently. CommandBus is
a Func for exactly this reason; the social mounts resolve it inside
each lambda. Every trade command - open (use + drag), accept (the
"unpressable" Trade button - the click FIRED, the publish died),
Clear All, close, and drop-on-grid staging - died on that one captured
bus. All six lambdas now resolve the Func per call.
Also: ID_SecureTrade_TotalItemsLabel probe-verified token-free
(fragments ["Total Items: ", ""], one ITEMS variable 0x004E8A23) and
composed via ResolveTemplate - the count texts read retail's exact
"Total Items: N". AD-95 RETIRED same-day.
The pre-feature stub-toast test row (drag-on-player option-on expecting
"Secure trade is not open.") now pins the SecureTradeRequested seam
instead. App suite 4,991/3 skips.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
layout-persisted; deeper [trade] wire probes
A session that ended mid-trade saved Visible=true for "secure-trade" in
the window-layout file, restoring an empty open trade window at every
launch. Added to the stateManagedVisibilityWindows set beside
Combat/JumpPowerbar/ExternalContainer/Vendor - visibility belongs to
RuntimeTradeState's open/closed lifecycle exclusively.
Also carries the round-2 [trade] probes for the still-open "open never
registers" diagnosis: the round-1 log proved the request seam fires
(4x "request partner=0x50000001 item=0 open=False") but no window ever
opened - the new probes bracket the command router (open-cmd sent
flag), the wire send (wire-open seq), and the inbound RegisterTrade
apply, so the next gate log pinpoints whether the send leaves the
client and whether ACE replies.
App suite 4,990/3 skips.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
and [trade] seam probes
- Use on a selected player did nothing: the world Use path
(SelectionInteractionController.RequestUse) dispatches 0x0036 directly
and NEVER consults DetermineUseResult - lane C's "both paths already
classified" claim described the POLICY's capability, not a live
caller (the verify-subagent-claims lesson, again). Retail's
CPlayerSystem::UsingItem @0x00562F70 consults it and routes result 5
(another PLAYER) to AttemptToOpenTradeNegotiations @0x0056DEE0, peace
mode only, with no Use send. Ported as
ItemInteractionController.TryOpenSecureTradeWithPlayer, called at
RequestUse entry.
- The broken window: both grids rendered their raw authored strip art
with no cell layout (the gold-ring tile was the naked track). They now
get the vendor strips' exact single-row 32px config + the authored
empty-slot art resolved through ItemListCellTemplate (cell-template
attr 0x1000000E -> 0x1000033A).
- Drag-onto-player "nothing happens": the policy chain reads correct
end-to-end in code (pick -> PlaceIn3D -> StartSecureTrade -> event ->
cmd), so TEMPORARY [trade] probe lines now bracket every seam
(drag-release pick, policy arm, controller request, use-on-player) -
the next gate log pinpoints the break if it persists. Stripped once
the two-client gate passes.
App suite 4,990/3 skips.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
authored gmSecureTradeUI window, and both retail open paths
Three-lane research first (docs/research/2026-08-14-trade-lane{A,B,C}):
retail gmSecureTradeUI decode, the byte-exact ACE/decomp/holtburger
three-way wire agreement, and the acdream seam map (which found both
open paths ALREADY classified by the ported policy - OpenSecureTrade on
Use-a-player, StartSecureTrade on drag-item-onto-player with the
DragItemOnPlayerOpensSecureTrade option - dead-ending at a stub toast).
- Core.Net: TradeRequests builders (0x1F6-0x204, retail's CM_Trade
senders byte-checked against ACE's readers; the ACE-discarded
AcceptTrade echo carries zero-count item lists - AD-94), corrected +
completed inbound parsers (0x1FD-0x208; the old AddToTrade parser
missed the SIDE dword, TradeFailure missed the reason), delegate-hole
registrars, six WorldSession sends. 10 golden-byte tests.
- Runtime: RuntimeTradeState, the third sibling J-owner (fellowship/
allegiance shape): session-scoped, clears at generation reset (new
stage Trade=14), staged teardown stage 11 (Identity/EntityObjects
shift 12/13, TeardownStageCount 14 - the FA2-era per-stage-flag test
caught the mapping exactly as designed), combined ownership ledger,
event routing with ACE's wrong-initiator RegisterTrade landmine
honored (partner = whichever guid is not mine). 7 conformance tests.
- App: SecureTradeUiController binds the dedicated authored LayoutDesc
0x2100000D (root 0x1000007A - gmSecureTradeUI::PostInit's exact ids):
partner name/status/count/grid, the authored 'Trade' accept toggle
(accept <-> decline withdraw), 'Clear All' (ACE clears BOTH sides -
surfaced honestly), the X close, drop-on-your-grid staging, per-mode
accept cues (partner icon's authored Highlight state + Trade button
Selected latch). Mounted via the vendor recipe (nine-slice chrome,
hidden until RegisterTrade). ItemInteractionController's two policy
arms now raise SecureTradeRequested instead of the stub toast; the
drag path queues the dragged item until the window registers
(ClientTradeSystem::AttemptToTradeItem @0x0056DF80's shape).
Register: AD-94 (accept-echo zero-count lists), AD-95 (numeric-only
count texts pending template verification).
Suites: App 4,990/3, Core.Net 905, Runtime 1,626 - all green. The
panel itself is user-gate acceptance (two-client connected trade), the
#372-class lesson: fixture-green alone is not acceptance for a mount.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
From the 2026-08-14 highres verification: acdream always runs at retail
MAX texture detail (Textures[0] + unconditional client_highres.dat).
Retail's two knobs (ID_Option_HighResChange gating LoadHighResDat
@0x004FA250; Landscape/Environment TextureDetail as a mip-chain start
index @0x0044C3C8) are recorded with their decomp anchors and the
acdream-side seams. Post-M4.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
machine + UiText per-state string swap (user retail gate)
The state side already worked (FriendsState replaces the full entry and
bumps Revision on 0x0021 OnlineStatus updates; the parser reads full
FriendData for every update type). The UI side had two gaps, exposed by
the installed-DAT row-template probe (template layout 0x2100005D root
0x10000519):
- The row authors TWO cells: the LEFT name text 0x1000051A whose
Online (0x10000054) / Offline (0x10000055) PassToChildren states
cascade into the RIGHT status grandchild 0x1000051F, which authors
per-state 'Online'/'Offline' strings AND per-state colors (retail's
green Online). The controller's FindDeepest binding wrote the NAME
into the STATUS cell (the deepest text IS the status grandchild) and
never flipped the state machine - so the status column never showed
or updated anything.
- UiText had no per-state authored-string swap: ApplyDatState switched
sprite + color per state but never the 0x17 string. Ported now
(second consumer of the mechanism after the powerbar caption):
DatWidgetFactory pre-resolves each state's authored string;
TrySetRetailState swaps the line, colored by the SAME state's
authored 0x1B.
SocialFriendsPageController now binds the name to its own cell and
flips the authored Online/Offline state per friend on every
Revision-driven rebuild - the cascade renders the status cell exactly
as retail's gmFriendsUI does, green Online included.
App suite 4,989/3 skips (new per-state swap conformance test).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
gate; supersedes CH round-1 item A)
The user's retail description matches the decomp exactly:
charge_jump @0x005281c0 has NO grounded check - it refuses only 0x49
(CanJump encumbrance) and 0x48 (fallen/crouch-family forward commands).
Pressing jump while airborne begins the powerbar and charges normally.
The 0x24 "You can't jump while in the air" comes exclusively from the
RELEASE path (ClientCombatSystem::DoJump @0x0056B110 ->
CMotionInterp::jump -> jump_is_allowed, whose airborne 0x24 our port
already carries test-pinned). A charge held through landing executes a
normal jump on the grounded release.
PlayerMovementController's input orchestration now mirrors
CommenceJump/DoJump:
- Press edge: ChargeJump() decides; a refused charge (0x48/0x49)
reports and never begins the bar (retail's jump_pending stays 0).
The invented airborne press-edge 0x24 report (CH user-gate round 1
item A - added when the press/release split was not yet known) is
deleted; CommenceJump's in-air fallback text is unreachable with a
faithful charge_jump.
- Hold: accumulates grounded OR airborne; leaving the ground mid-charge
no longer force-fires the jump.
- Release: fires jump(); an airborne release refuses 0x24 there.
Tests: the round-1 press-edge test is replaced by two release-semantics
tests (airborne release reports once; held-through-landing grounded
release jumps silently). Runtime 1,619, App 4,987/3, Core jump family
159.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
gate; the DAT authors it)
The raw-property probe settles it exactly as the user reported: the
powerbar caption's mode STATES author their OWN justification (0x14
Enum=0x3 = Right on JumpMode/MeleeMode/MissileMode) while the element
default stays centered. ElementInfo.HJustify only ever read the
effective DEFAULT state, so the earlier "authored Center" conclusion
measured the wrong state.
The meter's absorbed state-label entry now carries the state's own
authored alignment (state 0x14 wins, element-level HJustify as the
fallback, ElementReader's same enum mapping), and the caption draw
aligns accordingly - 'Height' sits at the bar's right edge, retail's
placement. Live vitals labels keep their centered draw.
App suite 4,987/3 skips.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Retail authors ONE caption element per bar with per-mode state strings,
switched by a PassToChildren state cascade (gmPowerbarUI::
RecvNotice_BeginPowerbar @0x004DA730 sets 0x10000042 Jump / 0x10000043
Melee / 0x10000044 Missile / 0x10000045 DDD; installed-DAT probe
confirmed every string + PassToChildren flag).
- Jump bar (user gate): the floaty powerbar's caption child (0x10000035:
JumpMode 'Height', authored HJustify=Center over the bar) was dropped
by UiMeter's child absorption. The stateful-fill meter build now
absorbs it into per-state labels; TrySetRetailState latches the
caption and OnDraw shows it when no live Label provider is bound.
JumpPowerbarController's existing JumpMode flip now surfaces 'Height'
with zero controller changes. The mount gained the string resolver the
Build call never passed.
- Combat bar (user gate): label 0x10000052 authors 'MeleeCombat' ->
'Power' and 'MissileCombat' -> 'Accuracy'; the controller latched the
MELEE string once at bind. CombatUiLabels now resolves both authored
strings and OnCombatModeChanged sets the mode's string - switching
live when swapping melee <-> missile weapons in combat. Also fixed
the mode-state flip target: the states live on the BASIC PANEL
(0x1000005C, PassToChildren), not the layout root (Hide/ShowDetail
only) - the old _root flip was a silent no-op.
New env-gated ACDREAM_PROBE_POWERBAR layout probe (kept, house
pattern). App suite 4,987/3 skips.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
move-cursor border band
- Empty-state text (round 3): the literal-\n split was correct but each
authored LINE rendered as one clipped run. Retail word-wraps each
authored line within the element extent (its GlyphList draw - the
same wrap RetailConfirmationDialogView already uses). Multiline
authored text now wraps through UiText.WrapWords against the widget's
LIVE width/font/color (cached per width+font+color, re-read per call).
Single-line authored labels keep their one-run shape - re-wrapping
every label is a client-wide change no gate asked for.
- Move cursor (round 3): "the frame won the hit-test" is not a border
test - windows whose interior is not fully covered by children (the
inventory panel's empty regions) resolve those pixels to the frame
too. The border is now a geometric 8 px band along the window's outer
edge, AND the frame must win the hit-test so border-adjacent content
keeps its own cursor. Resize-edge claim still takes precedence;
whole-surface dragging unchanged.
App suite 4,984/3 skips (new BuildText_MultilineAuthored wrap test).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
refused-drop yellow notice
Item 4 (confirmation dialogs missing text + names): the missing retail
mechanism was StringTable template substitution - an entry is N+1 literal
fragments interleaved with N named variables, composed by
StringTable::GetString @0x004300D0 (no-metalanguage branch @0x004303B7).
ACE sends the bare player name for types 1/4; retail's OWN CLIENT wraps
it. Ported as DatStringResolver.ResolveTemplate (PLAYER hash 0x05506DA2,
the exact compute_str_hash space; Chorizite stores the variable hashes
directly):
- Server-driven type 4 -> ID_Fellowship_FellowshipRequest, type 1 ->
ID_Allegiance_AcceptSwearConfirmation, injected into
GameplayConfirmationController; null resolve falls back to the bare
wire message, never invented English. The 2/3/5/6 " Continue?" family
never consults the composer.
- Local Swear/Break/Kick: the bind-time fragment-0 latch (which showed
the dangling "Do you wish to swear to ") is replaced by click-time
ResolveTemplate with the target's name.
All five templates verified token-free in the installed DAT - this is
NOT a StringTableMetaLanguage port (AD-81's engine caveat stands).
Item 5 (refused drop shows nothing; retail shows yellow top-center
text): the prevRequest latch was ALREADY ported (InventoryTransactionState);
what was missing was the consumer. InventoryTransactionState now raises
RequestFailed(request, weenieError) when a 0x00A0 clears the latch;
ItemInteractionController composes ServerSaysAttemptFailed @0x0058EAE0's
"The <item> can't be <verb>" (verb table + suffix map ported verbatim in
Core's InventoryFailureMessages, NAME_PLURAL for merge/split) and routes
it as LogTextType 0x1A ClientLocal -> the SpewBox, retail's yellow
top-center line. The dispatcher's second leg (@0x0055B342) also runs:
outside the 7-code exclusion set, WeenieErrorMessages resolves per-code
text/destination; 0x426 AttunedItem has no row in either place beyond
the verb line - faithful single-line output.
Register: AD-85 narrowed to its numeric-field item, AD-81 amended (the
token-free interleave is now ported; meta-token engine + FormatName
remain), AD-93 filed (wire-guid-match vs retail's latched-guid
preference; no Move/Wield latch kinds).
Tests: +2 InventoryTransactionState failure-latch, +5 ResolveTemplate
(constructed StringTable fixtures), +1 composer injection, +1 end-to-end
refused-drop line. Core 4,697/1 skip, App 4,983/3 skips.
Research: docs/research/2026-08-13-confirm-and-weenie-error-display.md
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
empty state, retail amber row selection
- Move cursor (user-directed, ALL windows): HoverWindowMove now
advertises only where the window frame element itself wins the
hit-test - its border pixels; interior points resolve to content
children. Matches retail's Dragbar-chrome-only move cursor.
Whole-surface dragging still works, it just does not advertise.
- Empty-state text (round 2): the DAT stores the LITERAL two-character
escape backslash-n (probe-verified - the dump printed the escape, not
line breaks), so the round-1 newline split never matched. Escapes are
normalized before splitting in DatWidgetFactory authored text.
- Selected fellow amber (user: "check retail"): probe-verified - the
row name band 0x10000282 AUTHORS the retail selected-row art
(DirectState 0x06001450 + Highlight 0x06001451, the amber). Selection
flips the band's ActiveState to Highlight; no invented tint.
App suite 4,976/3 skips. Confirmation-text + refused-drop-notification
research (the round's items 4-5) lands as part 2.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
One user-ordered batch across the FA social panel + world selection.
Every root cause was probe-proven before the fix (new
ProbeSocialClickRouting in SocialPanelLiveMountProbeTests - production
window mount + real UiRoot hit-tests + a synthetic click):
1. STUCK CHECKBOXES (fellowship x4, allegiance x1, "always checked /
can't change any options"): the authored checkboxes carry DAT
ToggleBehavior, so UiButton SELF-FLIPS Selected at MouseUp - the old
handlers read the flipped value and wrote the ORIGINAL back, snapping
every click to where it started (the probe recorded (id, oldValue)).
Fix: SuppressSelfToggle (the CH6a/b mirror discipline) + derive the
next value from the STORE; the per-tick seeding mirrors it back.
2. UNCLICKABLE ROSTER ROWS ("only get the move window cursor"): the row
name text is display-text ClickThrough=true, which the hit-test walk
skips regardless of HandlesClick - the wired OnClick was unreachable.
Fix: UiText.OnClick assignment now clears ClickThrough (central,
documented); the stats text gains the same select handler so most of
the row's width selects the fellow.
3. TRUNCATED EMPTY-STATE ("You do not belong... To create MISSING"):
the authored string resolves COMPLETE (three sentences) but embedded
'\n's rendered as one clipped line. DatWidgetFactory now splits
authored strings into one Line per newline, with the provider still
re-reading DefaultColor live (the state-color contract - caught by
BuildText_AuthoredLineTracksStateFontColor).
4. FELLOW NAMES WHITE (user-directed): the AD-82 invented leader-gold +
selection-blue tints are deleted; names always white (register row
narrowed).
5. ALLEGIANCE HEADER LABELS: bare "0"/"0" -> "Followers: N" / "Rank: [N]"
(user-specified format; the full retail StringInfo composition stays
AD-85's gap), monarch block matching.
6. FRIENDS/SQUELCH LIVE (AD-79 mostly retired): Add friend (name box ->
0x0018, retail clears the box - Request_AddFriend @0x0048D240),
Remove (row-click selection -> 0x0017), Appear Offline (CharacterOption
0x27 via the immediate 0x0005 auto-save, ACE pushes FriendStatusChanged
to your friend-of list), Squelch Character/Account add-by-name
(0x0058 guid0/type AllChannels + 0x0059) and Remove for the selected
row. The wire beneath (builders, WorldSession sends, Runtime commands,
parsers) existed end-to-end since J4.1/FA1 - this is panel wiring only
(docs/research/2026-08-13-social-wire-completion.md, committed here).
Send Tell stays inert (not in the order; AD-79's remainder).
7. WORLD SELF-SELECTION ("clicking my own char should select myself"):
retail has NO self-exclusion (CPhysicsPart::Draw @0x0050D823 arms
every physobj; RecvNotice_SmartBoxObjectFound @0x004E5BAE selects
unconditionally) - the includeSelf gate was an unregistered
divergence, now removed on both the left-click and right-click paths.
Element roles were probe-measured, never guessed (Add 0x10000514 /
Remove 0x10000515 / Send Tell 0x10000516 / Appear Offline 0x1000052C /
name field 0x1000051B; Squelch: field 0x10000540, Remove 0x10000547,
Squelch Character 0x1000054B, Squelch Account 0x1000054C).
Register: AD-79 mostly retired, AD-82 narrowed. Known remainder, filed
not hidden: the fellowship page's authored 600px content vs the 362px
viewport leaves Dismiss/Assign-Leader below the fold until the window is
resized taller (probe-measured; candidate follow-up).
Tests: Checkbox_Click fact rewritten to the mirror contract (both
directions), monarch-followers label updated, includeSelf expectation
updated, probe extended (click routing, synthetic click, action-widget
role dump). App suite 4,976/3 skips.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
position memory, unified monitor, maximized restore; AD-92
Dual-lens Opus review of e56aa511 (reports committed under
docs/research/). The consolidated corrections:
- Mechanism M1 (load-bearing): on Windows, Silk's GLFW error callback
QUEUES exceptions on a static list instead of throwing - they detonate
later at window close, which is exactly #388's original two-stage
crash shape. catch(GlfwException) was dead code here and a failed
SetWindowMonitor "succeeded". Success is now judged by the NATIVE
POST-CONDITION (GetWindowMonitor after the call) on both enter and
exit; the catches remain only for the throwing platforms.
- M2 (both lenses): same-mode fullscreen re-apply is a no-op BEFORE any
native work (new IDisplayModeSwitcher.CurrentFullscreenMode). Every
Display-backed Config row applies per change - sliders per DRAG TICK -
so without this every tick while fullscreen re-issued a real
display-mode change.
- M3/M5 (both): the remembered windowed placement is process state (two
target instances exist - startup and live-save); a fullscreen boot now
exits through either instance to the real placement, not the (60,60)
literal.
- M4 (both): the switcher resolves the WINDOW'S monitor (attached
monitor when fullscreen, else IWindow.Monitor's index into the GLFW
array - the same monitor DisplayModeCatalog enumerated), primary only
as a last resort; the offered-list/switch-target mismatch is gone.
- Blast M2b: the offered-mode validator falls back to the SAME static
ladder the dropdown falls back to - Full Screen is no longer a
permanent silent no-op on catalog-less hosts (the switcher's own
monitor-mode-list check remains the hard guard).
- Blast M3: a windowed pick on a MAXIMIZED window restores it first
(Size writes are silently ignored while maximized; the deleted
WindowState=Normal write used to do this incidentally). New
IWindowedSizeSurface.IsMaximized/Restore.
- Mechanism M5: no silent bail-outs - the unparseable-resolution
fullscreen path logs, and the failure line no longer claims "staying
windowed" when the state is unchanged (#392 noted inline).
- Q1 nit: one cached Glfw wrapper (per-call GetApi allocated + took a
native refcount); IsFullscreen/CurrentFullscreenMode guarded.
- AD-92: highest-refresh-for-WxH + refuse-and-log versus retail's
pass-through-and-error ForceDisplayResolution.
Known-open tail, filed not hidden: #392 (persisted-flag divergence on a
refused enter - needs an apply-result seam); the mechanism report's
pacing-refresh WATCH rides the same seam.
Tests: +3 (same-mode no-op, unparseable-while-fullscreen refusal,
maximized restore-before-write). App suite 4,975/3 skips.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Slice 5+6 of the display block, one coherent unit (they share the state
machine the goal's dual review covers).
GlfwDisplayModeSwitcher (#376) ports retail's fullscreen semantics -
Device::ForceDisplayResolution @gmClient::Init 0x004047af is a REAL
video-mode change - through native glfwSetWindowMonitor on the same
IWindow.Native.Glfw handle path #348's cursor cache proved. Primary
monitor (retail's primary display device); refresh = the monitor's
highest for the picked WxH; the windowed placement is remembered for the
exit path; every failure is a no-throw (bool, reason) result.
SilkRuntimeDisplayWindowTarget.Apply (#388) becomes the state-aware
machine: fullscreen target = validated native mode switch (mode must be
in #391's DisplayModeCatalog - an offered mode is supported by
construction, making the "Graphics mode not supported" crash class
unreachable from the dropdown); windowed target while fullscreen = the
native exit (which sets the client size itself); plain windowed pick =
the proven #387 size write. A raw Size write NEVER happens against a
fullscreen window - on GLFW that is a video-mode request, and an
unsupported one was the exact unhandled-GlfwException that killed the
user's 2026-08-13 session. The old Silk borderless WindowState path is
deleted from the apply. New IWindowedSizeSurface narrows the window
dependency so the machine is unit-testable (FakePacingSurface idiom).
Live-verified on this machine (goal-sanctioned automated run):
display: fullscreen mode switch 1920x1080@300 -> framebuffer resize
event 1920x1080 -> vulkan: swapchain recreated 1920x1080 ok=True ->
graceful close, desktop mode restored.
Tests: 5 state-machine facts (validated switch/never-size-write,
unoffered refusal, failed-switch usability, native exit, plain windowed
write). App suite 4,972/3 skips. Gate script sections D4-D6 written
(black-screen-risk steps flagged). Dual Opus review of the pair follows
as its own round.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Decomp-first per the block's rule: the research doc
(docs/research/2026-08-13-retail-ui-display-change.md, committed here)
pulled retail's actual mechanism before any code. A display change runs
UIElementManager::RefreshEvent @0x0045C530 ->
UIElement::UpdateForParentSizeChange @0x00462640, which unconditionally
re-applies every floating window's own clamping MoveTo override
(x = max(0, min(x, parentW - selfW)) - top-left priority, oversized
windows pin to 0), then broadcasts global message 0xE whose sole
listener reloads the per-resolution auto layout. No proportional moves,
no resets; retail saves layouts only via @saveui.
Port: RetailWindowLayoutPersistence.ClampAllToScreen() is the cascade
clamp (no store I/O; _restoring suppresses the per-move save so a live
drag-resize cannot write settings.json per frame), and
RetailUiRuntime.Draw carries a two-step screen-size edge detector:
change frame -> clamp; first stable frame -> one
RestoreAll(saveBack:false) per-resolution reload (the 0xE analog; no
lazy save-back, matching retail's save-only-on-command). The login
restore path already used retail's exact clamp math (Apply) - the live
trigger was the missing half, which is precisely the stranding the user
reported.
Deliberate deviation, register AD-91: retail's gmFloatyChatUI windows
have NO clamp and can strand; the block's requirement ("UI windows must
stay reachable") clamps every registered window uniformly.
Tests: 5 new persistence facts (clamp/top-left-pin/no-move/no-save-on-
clamp/no-save-on-live-reload). App suite 4,967/3 skips. Gate script
section D3 filled in.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Dual-lens Opus review of 7e0c1303 (reports committed under
docs/research/). The law, gate, and vertical application are CONFIRMED
at instruction-byte level against the PDB-paired acclient.exe (the BN
text FPU-elides this whole area); the fix round addresses the findings:
- Blast MUST-FIX 1: real schema migration instead of a hand-edited dev
file. SettingsStore v2->v3: a pre-v3 display.fieldOfView was the
applied vertical FOV in degrees; v3 means retail's m_fGameFOV.
LoadDisplay migrates on read - the untouched old default 60 maps to
the retail default 90; a deliberate other value preserves its visible
16:9 framing (x (16/9 - 0.1)), clamped to the registered [10,160];
the next save stamps v3 and migration never reruns. The dev
settings.json hand-edit was reverted so the migration owns it.
- Blast MUST-FIX 2 / mechanism M2: the Field of View now applies LIVE on
Save (retail: Render::GRPCallback_OnRenderPreferenceChanged @0x0054d999
-> SmartBox::SetDefaultFov). RuntimeSettingsTargets gains the camera
graph and applies through ApplyDisplayWindowState - the update-phase
seam, deliberately NOT the render-phase preview path (the review's
WATCH-3 cull-vs-raster landmine).
- Mechanism M1 -> register row AD-90: retail's divisor aspect runs
through the Render.AspectRatio preference (ComputeAspectForViewport
@0x0054f150, (w/h) x pref x 0.75) - exactly raw w/h at the registered
default, which is what acdream assumes; retail's NaN-through-the-gate
quirk (M3) is folded into the same row as deliberately not reproduced.
- Docs: RetailFieldOfView now cites the decisive vertical proof
(D3DXMatrixPerspectiveFovLH fovy slot @0x0059ab71), the unconditional
SmartBox::RenderNormalMode site, and M4's exact horizontal numbers
(89.0/83.9/80.6 deg); the Config FOV row comment updated to LIVE.
- Blast WATCH 4 disposition: the 15 replay-harness PI/3 constants stay -
they are CAPTURE-TIME camera parameters for recorded fixtures, not
production framing; changing them would invalidate the replays.
Tests: +6 SettingsStore migration facts, +1 live-apply fact.
App suite 4,962/3 skips; UI.Abstractions 922.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
User-directed (2026-08-13): "we should only support modern resolutions.
Not any old format." New DisplayModeCatalog enumerates the window's
monitor (Silk IMonitor.GetAllVideoModes) once at GameWindow load and
curates via a pure, tested rule: modern widescreen families only
(16:9/16:10/21:9/32:9 within 2.5%), at least 1280 wide, must fit the
desktop (an impossible windowed pick is not offered - the measured
3840x2160-on-2560x1440 silent clamp class), desktop mode always
included, refresh-rate duplicates collapsed, ascending order.
The Config Resolution row consumes the catalog through two new optional
Bind parameters; its Defaults value becomes the desktop's own mode.
Fixture/headless callers keep the static preset ladder, which now drops
800x600 and is pinned by test to pass the same curation rule (the OP6 S4
"default must be re-selectable" invariant holds on both paths).
Deliberate retail deviation, register row IA-22: retail listed the
adapter's complete enumeration including 4:3 legacy modes and authored
800x600 as the Config default (gmConfigUI::InitOptions
SetDefaultValue(0x03200258); gmClient::Init @0x004047af). The catalog is
also the designated fullscreen mode-switch validation source for
#376/#388 - an offered mode is supported by construction.
Tests: DisplayModeCatalogTests (8 - filter/clamp/dedupe/sort/ultrawide/
desktop-inclusion/fallback-consistency); ConfigOptionsPageControllerTests
row-12 default updated. App suite 4,961/3 skips; UI.Abstractions 916.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Retail's world-camera FOV is not a constant: the applied vertical FOV is
m_fGameFOV / (viewportAspect - 0.1), recomputed on every aspect or
game-FOV change (CreatureMode smartbox sites 0x00452b2f/0x00453b14),
gated by Render::SetFOVRad's open (0, pi) acceptance (0x0054b2d0 -
rejected results keep the previous FOV). m_fGameFOV defaults to pi/2 =
90 degrees (0x00454649) and is what the Field of View option sets in
degrees (0x00451e6a; registered range [10,160] default 90 -
gmClient::InitUIPreferences @0x004035b0). Net effect: the horizontal
view stays ~85-90 degrees across aspect ratios; wide screens trim the
vertical slice instead of ballooning the sides.
acdream hardcoded FovY = pi/3 = 60 degrees on all four world cameras,
aspect-independent, and the Config slider wrote raw vertical-FOV
degrees. New: RetailFieldOfView (the law + gate, decomp-cited),
CameraController.GameFovRadians + SetGameFov + one ApplyProjection
chokepoint recomputing every camera on SetAspect/SetGameFov/
EnterChaseMode/RestoreState; ApplyFieldOfView now feeds the law;
DisplaySettings.Default.FieldOfView 60 -> 90 (the retail registered
default; the stored number changed MEANING with this commit).
The same seam closes a second latent bug the 2026-08-13 "squished" gate
report exposed: SetAspect only ever updated Orbit/Fly - the CHASE
cameras (the ones the player looks through) kept their creation-time
aspect across every mid-session resize, drawing the world at the old
shape stretched onto the new viewport.
The paperdoll camera stays outside the law by design (retail portrait
mode is UseSharpMode, not smartbox - DollCamera's own doc).
Tests: RetailFieldOfViewTests (golden law values at 4:3/16:9/21:9, the
constant-horizontal property, the rejection gate, controller propagation
incl. chase attach/restore + rejected-law aspect-still-propagates);
DisplaySettingsTests + RuntimeSettingsControllerTests updated to the new
semantics. App suite 4,953/3 skips; UI.Abstractions 916/0. AD-89 retired
in this commit; user settings.json migrated 60->90 by hand (stale
pre-port default).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Both from the 2026-08-13 display gate session. #389 carries the full
decomp-verified retail FOV law; #390 requires the retail reposition
mechanism from the decomp before any clamp is implemented.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
GameWindowSlice8BoundaryTests.FramebufferResize_IsOneTypedOwnerHandoff
correctly rejected the log line added to GameWindow.OnFramebufferResize
— the window callback is contractually a one-line handoff. The line now
lives in FramebufferResizeController.Resize after its zero-size gate,
which is also the better home (one owner, all callers covered). Full
Debug App suite 4,941/3 skips.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three rare-event log lines so any future resize report is diagnosable
from the launch log alone: 'window: framebuffer resize event WxH',
'vulkan: swapchain recreated WxH ok=', and 'display: resolution pick
WxH (window was WxH)'. An instrumented live run on the Windows AMD box
shows the full chain firing for both the programmatic resolution apply
and external window resizes, and screen captures at 784x561 vs 1584x861
confirm fixed-pixel UI with a true pixel-count re-render.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
User report: resolution picks (and window drags) stretched the image
instead of changing the pixel count. Root cause: Campaign V slice V11
deleted the GL viewport target and left a null target, assuming the
driver's OUT_OF_DATE/SUBOPTIMAL acquire/present results would drive
swapchain recreation on resize. That is driver-dependent and
spec-insufficient — this machine's Windows AMD driver keeps presenting
the stale-extent swapchain scaled to the new window indefinitely, so
OnFramebufferResize only ever updated the camera aspect while every
pass (UI included) kept rendering at the old extent.
Fix: SwapchainRecreateViewportTarget implements the existing
IFramebufferViewportTarget seam for Vulkan and arms
VulkanGraphicsContext.RequestRecreate() on every resize event; the next
PrepareFrame rebuilds the swapchain at the live FramebufferSize (bursts
collapse to one recreation, stale events cannot install a stale extent,
minimised sizes stay gated by FramebufferResizeController).
Tests: SwapchainRecreateViewportTargetTests (target contract, size-
agnostic arming, null hook, controller-to-target end-to-end with the
minimised gate). Full Debug App suite 4,941/3 skips.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
LandblockStreamer.HandleJob's near-payload check is "fail loud in Debug
builds and strip in Release" (its own comment, with a Debug.Assert at the
check). FarLoad_StripsEnvCellsAndPhysicsEvenWhenEntityListIsAlreadyEmpty
feeds a deliberately-buggy far factory to verify the Release strip — so
under Debug the assert fires, the test host's listener turns it into an
exception, and the job publishes Failed BY DESIGN. The test asserted the
Release outcome unconditionally and therefore failed on every full Debug
App run (found 2026-08-13 during the #385 session; every campaign gate
runs Release, which is why it never surfaced). It now asserts the Failed
result + assert message under DEBUG and the strip under Release. Verified
green in both configs; full Debug App suite 4,937/3 skips.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
User gate report (Campaign OP happy-testing round, 2026-08-13): every
Config-tab dropdown drew its text gold + left-aligned and its popup a
fixed 6 rows regardless of item count. All three were unmeasured styling
divergences — the authored data (new probe menuprobe3, live DAT) says:
- button label child 0x10000355: fontColor white, hJustify=Center
- row template 0x1000035A: fontColor white, hJustify=Center
- popup ListBox 0x10000358: edge-docked L=T=R=B=1, the authored condition
arming retail UIElement_Menu::RecalculatePopupSize @0x0046caf0 —
popup resizes to the ListBox's summed content height, uncapped
(0x0046e5f4..0046e66c via ResizeScrollableArea's 0x32 broadcast)
UiMenu gains three opt-in properties (ButtonTextCentered,
ItemTextCentered, PopupSizeToContent) plus retail Open @0x0046cc42's
empty-list gate; chat + vendor keep the class defaults, so their shipped
behavior is untouched. ConfigOptionsPageController.ApplyMenuChrome wires
all four corrections for the 8 Config menus with the probe citation.
The same probe found vendor's authored popup ListBox is ALSO docked while
our vendor dropdown ships G5's fixed 6-row window — filed as #386 +
register row AD-88 (UNCLEAR: the G5 retail screenshot and the decomp
mechanism conflict) instead of silently reworking a user-gated surface.
The "resolution change resizes the window" observation from the same
report is #374's designed windowed-mode behavior (display-mode switching
is #376/#377) — no change.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The retail four-tab social panel (Fellowship & Allegiance) is
code-complete: all six slices landed and reviewed (dual-lens Opus review
-> fix round -> narrow re-review each). Fellowship two-session flow proven
live (FA6 bot gate PASSED). Closeout bookkeeping:
- register AD count 66 -> 67 (AD-87, the deferred allegiance bot gate);
- plan status flipped to CODE-COMPLETE with the OWED connected gates +
#384 (allegiance-swear ACE non-response) called out;
- CLAUDE.md Current-state gains the Campaign FA paragraph
(per feedback_claude_md_staleness), pointing at the memory digest.
Owed: the user's connected gates (§FA3-§FA6 of
docs/research/2026-08-12-campaign-fa-test-script.md) and #384's
ACE-console disambiguation. Full suite 13,304/4/0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Plan ledger: fellowship two-session automated gate PASSED live 2026-08-12
(five of six runs reproduced the decisive cross-session assertion); the
allegiance bot gate is DEFERRED behind AllegianceGateEnabled=false pending
docs/ISSUES.md #384, with commit citations for every fix this slice landed
(confirmation relay, name-matched proximity, the fellowship-only
finalization).
Gate script §FA6: the fellowship automated-gate recipe + actual PASSED
result (the two-session config, the six proof points per stage, the
literal decisive-assertion log lines), the allegiance deferral writeup,
and a new [TWO-CLIENT] manual step (25) the user's own connected gate can
run to help disambiguate #384 (ACE-side rule vs wire-builder defect vs
harness-specific drop) using two real graphical clients instead of the
testaccount/testaccount2 pair.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
docs/ISSUES.md #384 records the live-run evidence trail (six connected
runs, the 0.005 m distance diagnostic, the confirmation-arrival diagnostic
that never fires) behind AllegianceGateEnabled=false.
docs/architecture/retail-divergence-register.md AD-87 records the honest
divergence this deferral creates: the allegiance half of the FA6 bot gate
is written and wired but unverified end-to-end over the wire, unlike the
fellowship half which is proven live.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Six live runs against local ACE (testaccount/+Acdream as Leader,
testaccount2/+Horan as Recruit) converged on a clean split:
- FELLOWSHIP two-session gate PASSES live, reproduced in three separate
runs. The decisive cross-session assertion (the Recruit bot's own
RuntimeFellowshipState — a separate process's canonical Runtime owner,
not the Leader's local echo — flipping IsInFellowship=true,
MemberCount=2, LeaderGuid=<Leader>) holds every time. This ships as the
automated gate.
- ALLEGIANCE swear never completes: ACE returns nothing at all to
Event_SwearAllegiance (0x001D) — no 0x0274 confirmation, no 0x0020 tree
update, no WeenieError — even at 0.005 m separation (run6's distance
diagnostic ruled out retail's 2.0 m swear-distance gate). Ambiguous
between an FA1 wire-builder defect, an ACE-side rule this test pair
trips, or a drop; disambiguating needs an ACE server console this
harness doesn't have. Filed as docs/ISSUES.md #384 and
docs/architecture/retail-divergence-register.md AD-87.
AllegianceGateEnabled (static readonly, not const, to avoid a CS0162
unreachable-code build error from branching on a literal) gates every
allegiance-dependent stage in BOTH policy classes off by default:
Leader's WaitForVassal (skipped straight to the reconnect+teardown that
only need fellowship state), Recruit's Swear/WaitSwornSeed/Break/
WaitBrokenSeed (same). All of that code stays fully written and wired —
flipping the flag re-enables it for a follow-up investigation once #384
closes. WaitReconnectReseed on both sides now asserts fellowship-only
re-seeding when the flag is off, preserving the reconnect-idempotence
proof independent of the allegiance blocker.
The two live-run diagnostics added while investigating #384 (the
confirmation-arrival log line in HeadlessSessionHost's
OnConfirmationRequest, and LogDistanceToPatron in the Recruit policy) are
kept as permanent, clearly-labeled evidence for whoever reopens#384 —
neither is "TEMP, strip later."
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The second live gate run exposed a real environmental hazard: this shared
ACE dev instance has a THIRD player character online (+Je, guid
0x50000001), and after @teleallto it ended up nearer to the Leader bot than
the actual Recruit bot (+Horan, 0x5000000B). RuntimeFriendlyTargetQuery.
FindClosestOtherPlayer — "nearest ANY other player" — picked +Je, and the
fellowship recruit sent to it obviously never completed (confirmed live:
WaitRecruited/WaitForRecruit both timed out, both bots quarantined and
gracefully logged out cleanly).
RuntimeFriendlyTargetQuery.FindPlayerByName resolves the nearest player
whose streamed name matches exactly, with 3 new conformance tests
(preferring the named player over a closer stranger, returning null when
absent, and case-sensitivity/hidden/no-draw/self rejection).
FellowshipAllegianceGateCoordinator (AcDream.Headless.Policies) is a small
same-process, no-locking (single update thread) carrier for the Recruit
bot's own discovered character name — set by its own HeadlessSessionHost
the instant CharacterList selection resolves it, which IS D8's "discover it
live" mechanism, not a hard-coded value. Constructed once per
HeadlessProcessHost and threaded through HeadlessBotPolicyFactory.Create
into the Leader policy, which now name-matches instead of taking whichever
player entity happens to be closest.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The first live two-bot run exposed a real gap: retail always confirms an
incoming allegiance swear to the PATRON (0x0274 Character.ConfirmationRequest,
type 1) before ACE sends 0x0020/0x01C8 to either party
(docs/research/2026-08-11-fa-allegiance-wire.md §3.3) — and unlike
fellowship's FellowshipAutoAcceptRequests (which ACE honors server-side,
never even sending a confirmation), there is no auto-accept character option
for allegiance. HeadlessSessionHost wired OnConfirmationRequest to null, so
a headless bot silently dropped every incoming confirmation and the swear
never completed — both bots timed out waiting for TotalVassals/patron to
seed, confirmed live against ACE (both quarantined cleanly with graceful
per-character logout, proving the self-terminating design and existing
graceful-shutdown path both work correctly; this was an FA6 capability gap,
not an FA1-FA5 wire/state defect).
HeadlessSessionHost now latches the single outstanding confirmation
(matching retail's own one-dialog-at-a-time shape) and exposes
PendingConfirmation/RespondToConfirmation, cleared on every reconnect since
a stale context id would be meaningless post-reconnect. The gate's Leader
policy polls and blind-accepts any pending confirmation on every tick before
its own stage switch — the v1 substitute for a human clicking Accept, safe
because the gate's two sessions are its own known bots.
HeadlessBotPolicyFactory.Create takes two new optional delegate parameters
(default null, so cannot break other policy ids); the Leader gate policy
requires them non-null via a defensive constructor check.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
two-bot gate
Adds the infrastructure docs/plans/2026-08-11-fellowship-allegiance-campaign.md
D8 and docs/research/2026-08-11-fa-acdream-seams.md §6.2 call for:
- HeadlessBotPolicyDescriptor gains an optional typed Role
(HeadlessBotPolicyRole.Leader/Recruit) so two sessions selecting the SAME
policy id run different scripts — fellowship leader/allegiance patron vs
fellowship recruit/allegiance vassal.
- HeadlessBotPolicyFactory.Create widens from Create(string id) to
Create(HeadlessBotPolicyDescriptor, GameRuntime) — the gate policies need
RuntimeFriendlyTargetQuery, which (like its RuntimeHostileTargetQuery
sibling) takes the concrete GameRuntime rather than the narrower
IGameRuntimeView a policy's own Tick receives (IRuntimeEntityView's
snapshot carries no name/PWD-bitfield). The single call site
(HeadlessSessionHost.cs) already has the constructed runtime in scope, so
no new constructor parameter or cross-session coordinator was needed.
- FellowshipAllegianceLeaderBotPolicy / FellowshipAllegianceRecruitBotPolicy:
a full stage-machine pair covering proximity (retail's admin @teleallto —
"teleport everyone online to me" — needs no cross-session name sharing,
unlike @teleto <name>; D8's proximity requirement is load-bearing, recruit
fails without it), fellowship create+recruit, the D4 0x00A6 panel-open
declaration with a vitals-presence assertion, the decisive two-session
assertions (the RECRUIT bot's own RuntimeFellowshipState/
RuntimeAllegianceState flipping — not the Leader's local echo), a
mid-flow reconnect on both bots proving FA2's reset-and-reseed semantics
over the real wire, and teardown (disband / break) with matching
decisive-clear assertions.
Every pre-FA6 policy (idle, lifecycle-smoke, observer-movement,
portal-route-smoke, jump-probe) is unaffected; the widened factory
signature is the only touch point.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
RuntimeHostileTargetQuery only classifies hostile monsters (via
CombatTargetPolicy.IsHostileMonster) — the FA6 two-bot fellowship/allegiance
headless gate needs the OTHER bot's server guid as a FRIENDLY target instead.
RuntimeFriendlyTargetQuery.FindClosestOtherPlayer mirrors the hostile query's
shape exactly (same hidden/no-draw filtering, same landblock-absolute
distance metric), substituting the retail PWD-bitfield IsPlayer bit (0x8,
via the existing EntityCollisionFlagsExt.FromPwdBitfield decoder) for hostile
classification. TryGetName resolves the streamed WeenieHeader name for
reporting/logging.
4 new conformance tests mirror RuntimeHostileTargetQueryTests's fixture
pattern: cross-landblock distance, hidden/no-draw/self/non-player rejection,
null-without-player-or-target, and unresolved-guid name lookup.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The FA5 mechanism review found the offline-vassal name-grey
(OfflineNameColor) is an invented visual: retail's UpdateVassalsData
@004924c3 writes the vassal name with no colour change, and the offline
cue is EXCLUSIVELY the authored 0x100004AA marker (already wired,
SetVisible per online state). Removed OfflineNameColor; the vassal name
always renders in the normal white. The Allegiance page now carries NO
invented tint (unlike Fellowship's registered leader/selection tints).
Pinned by Allegiance_OfflineCue_IsTheMarkerOnly_NameStaysWhite (marker
visible iff offline, name always white). AD-82's FA5 addendum corrected
(it had described the now-removed grey as 'covered by the marker'); AD-86
count corrected seven -> nine.
Full Release suite: 13,297 passed / 4 skipped / 0 failed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
FA5 mechanism-faithfulness review of 7ed79eaf/bc29a1db/7e394cbf. Verdict
APPROVE-WITH-FIXES. Every high-stakes claim re-derived from the PDB-paired
2013 decomp: CF-1's unconditional 0x001F post-world arm (00490d59 sits
OUTSIDE the busy-count guard), the monarch/patron/self field sources
(UpdatePlayerData/UpdateMonarchData/UpdatePatronData), the SF-7
per-relationship gate, swear=world-selection/no-SetSelectedObject, and the
AD-86 ACE-zeroed-field citations all match retail.
MANDATORY live-mount probe RAN and PASSED against the real installed DATs
(1/1) — the scoped doubled-0x10000492 NotSame assertion and a full
production Bind() with zero "not found" held. FA5 unit suite 36/36 green.
One SHOULD-FIX (LOW): FA5 greys the offline vassal NAME (OfflineNameColor)
— retail's UpdateVassalsData @004924c3 sets the name with no color; the
offline cue is exclusively the authored 0x100004AA marker toggle. Either
drop OfflineNameColor or honestly register it (the AD-82 addendum's
"covered by the marker" framing understates it). Does not block the gate.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Suite-accounting SHOULD-FIX: the FA5 ledger/commit cite FA4's INTERMEDIATE
13,285 figure as the baseline and claim +11 net, but FA4 CLOSED at 13,286
(its 'Final full suite' figure) and the real net is +10 (verified per-file
[Fact] counts: SocialPanelControllerTests 22->31, Confirmation 4->5, probe
1->1) -- the ledger's own itemization already sums to +10, contradicting
its +11 headline. End figure 13,296/4/0 is itself correct; documentation
fix only.
Verified clean: all three Callbacks/Bindings construction sites pass the
widened Allegiance binding; every production accessor fed from a real seam;
the 0x001F and 0x00A6 toggles are independent edge-triggered latches with
no cross-talk (38 Fellowship tests green); ResolveWorldObjectName reuses
the Toolbar's ClientObjectTable read and ShowConfirmation is a pre-existing
shared method with no Fellowship collision; @allegiance info/0x027C path
untouched (52 Core.Net + 16 Runtime allegiance tests green); FA5 makes zero
Runtime changes; register 63->66 rows accurate (AD-84/85/86 + AD-82
addendum); NUL-fix correct and no residual control bytes in any of the 11
touched files.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A tool-layer artifact from the original FA5 commit (7ed79eaf) silently
replaced the leading and trailing ASCII space (0x20) in
`BlankSentinel = " blank "` with NUL (0x00) bytes -- verified byte-for-
byte via PowerShell (two NUL bytes total in the whole file, both
adjacent to the literal's "blank" text). C# tolerates an embedded NUL in
a string literal (it compiles and runs fine, since the constant is only
ever used as an internal dedup sentinel, never rendered), so this never
surfaced as a build or test failure -- caught only by an incidental
`file`/`grep -a` binary-content check while re-reviewing the finished
slice. Replaced the two NUL bytes with the intended spaces at the exact
byte offsets; swept every other file this slice touched (Runtime/App/
tests/docs) for the same corruption and found none.
No behavior change: full solution suite still 13,296 passed / 4 skipped
/ 0 failed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Register:
- AD-84 -- Swear button's missing "target is a player" enable-rule gate,
same class as AD-83's Recruit-button gap.
- AD-85 -- the unported StringInfo variable-substitution engine (AD-81's
same root cause) extended to the Allegiance page's numeric-only
followers/rank/experience-passed-up fields and its three local
confirmation dialogs (verbatim-or-bare-name, never invented).
- AD-86 -- ACE's deliberate zeroing of seven AllegianceProfile/
AllegianceData fields (officers, officer titles, MOTD, MOTD-set-by,
name-last-set-time, lock, approved vassal, timeOnline, allegianceAge),
dropped past acdream's own parse layer to match retail's own
gmAllegianceUI, which has no widget for any of them either.
- AD-82 addendum: the vassal-row click-target-only selection shares
point (3)'s limitation, but NOT the invented leader/selection tints
(point 1/2) or the Fellowship-only world-selection sync (point 4) --
Allegiance's list-selection message has no SetSelectedObject call.
Gate script: new docs/research/2026-08-12-campaign-fa-test-script.md
SFA5 section, mirroring SFA4's structure -- the CF-1 subscription steps
(including the reconnect-while-closed MF-3-REOPEN analogue), the SF-7
per-relationship monarch/patron steps, vassal-list steps, swear/break/
kick with their confirmations, the ACE-zeroed-field honesty note, and
full "what to report"/"explicitly not in scope" lists.
Plan ledger: FA5 row filled in against 7ed79eaf with per-item summary,
directly-measured totals (13,296/4/0, +11 net from FA4's 13,285/4/0),
and the two primary-source resolutions this slice needed beyond the
research docs (the self-rank field's live buffed-quality source, and
"your follower count" == _total_vassals, confirmed by a fresh targeted
decompile of UpdatePlayerData rather than inferred).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Campaign FA slice FA5. The Allegiance page (0x10000291) goes from FA3's
empty-state shell to fully live, wired against FA1's parser and FA2's
RuntimeAllegianceState/IRuntimeAllegianceCommands (both already shipped
the full command surface, including SetUpdateSubscription).
CF-1 (the corrected data subscription): 0x001F AllegianceUpdateRequest,
not 0x027B, is the panel's data source (0x027B/0x027C are text-only chat
per FA2 MF-2). Wired at retail's three arming points -- Bind's PostInit
attempt (almost always a pre-world no-op), the post-world EnteredWorld
seam (RedeclareAfterWorldEntry, UNCONDITIONAL -- does not check the
current latch, matching retail's own PlayerDescReceived arm and avoiding
the exact MF-3-REOPEN bug class FA4 hit for 0x00A6), and the visible
branch (SetPageVisible, edge-triggered, folded into
SocialPanelController's existing window-shown+active-tab conjunction
alongside Fellowship's 0x00A6).
Monarch/patron/self blocks: per-relationship empty-state gate (fix-round
SF-7) replacing FA3's coarse HasProfile-only gate -- the monarch block
hides when there is no monarch OR the monarch is the viewer; the patron
block hides when there is no patron OR the patron is the monarch (in
which case the monarch block's 0x10000490 sub-block reveals and its
label swaps to PatronSlashMonarchLabel). Field sources decompiled fresh
from gmAllegianceUI::UpdatePlayerData/UpdateMonarchData/UpdatePatronData:
0x10000251 is the ALLEGIANCE's own name (not the viewer's), follower
counts are TotalVassals/TotalMembers-1 directly off the wire, and the
"experience passed up" text (0x10000492, doubled -- scoped FindDescendant
under each of its two parents) is the viewer's own CpTithed under the
monarch/patron blocks and each vassal's own CpTithed in their row.
Vassal roster: flat list built via UiTemplateListBox.FlushPreservingScroll
in the FA4 roster-diff pattern (guid-set diff, in-place update on an
unchanged set), rendering in the bindings' own already-reversed order.
Swear/break/kick: each opens a local confirmation dialog
(RetailDialogFactory via ShowConfirmation) before sending, mirroring
retail's MakeSwearConfirmationDialog family -- Swear targets the WORLD
selection (via the same ClientObjectTable name resolver
ToolbarRuntimeBindings.ResolveName already uses), Break targets the
current patron, Kick targets the panel-local selected vassal row (no
world-selection sync for Allegiance, unlike Fellowship). The
server-driven "accept incoming swear" (ConfirmationType 1) needed no new
code -- GameplayConfirmationController already handles every type
generically; a new test verifies it explicitly.
Runtime/composition plumbing: DeferredGameRuntimeStateCommands gains
Allegiance{Swear,Break,Kick,SetUpdateSubscription}; SocialRuntimeBindings
gains the Allegiance view/command projections; SocialPanelController.
Callbacks.AllegianceSnapshot widens to a full
SocialAllegiancePageController.Bindings record, mirroring FA4's
Fellowship widening.
Tests: SocialPanelControllerTests.cs gains 10 tests covering the SF-7
gate (4), roster population, swear/break/kick wiring (3), and the CF-1
subscription arming points (2); GameplayConfirmationControllerTests.cs
gains the type-1 verification test.
Also extends SocialPanelLiveMountProbeTests.cs (production-mount
assertions: scoped 0x10000492 resolution, the vassal row template, the
checkbox, confirmation-dialog string resolution, and a full production
Bind() pass) -- not yet run against live DATs in this worktree (no
Documents/Asheron's Call present here).
Release build green; full solution suite 13,296 passed / 4 skipped / 0
failed (13,300 total), up from FA4's 13,285/4/0 baseline.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The re-fix moves the 0x00A6 re-declaration off the pre-world reset seam
and onto the post-world EnteredWorld seam, and stops the widget latch from
advancing on a dropped publish. Verified in the diff:
- SetPageVisible advances _pageVisible ONLY on RuntimeCommandStatus.Accepted
(the widget-level root of the REOPEN); a dropped Inactive publish leaves
the latch clear so the in-world attempt is not deduplicated.
- ResetSessionDeclaration (pre-world) now only clears the latch;
RedeclareAfterWorldEntry (new) does the re-evaluation, wired through
RetailUiRuntime.RedeclareSocialPanelAfterWorldEntry into
LiveSessionRuntimeFactory's EnteredWorld RestoreLayout delegate.
Seam ordering traced and confirmed inverse of the pre-world SessionDialogs
stage: StartCore runs ResetHostBeforeStart (pre-world reset, latch clear)
at :555, then ActivateCommands :639, _inWorld=true :642, and
ApplyEnteredWorld :644 -> LiveSessionHost.ApplyEnteredWorld ->
RestoreLayout delegate -> RedeclareAfterWorldEntry. So SetPanelOpen's
requireWorld gate is Accepted and 0x00A6 publishes on the fresh server.
Idempotent and load-bearing (the social panel isn't state-managed
visibility, so RestoreLayout fires no OnShown edge).
Tests model the world gate (fake returns Accepted only when in-world) and
would fail against pre-fix behavior: the widget test's second attempt is
deduplicated if the latch advances unconditionally; the reconnect test's
DoesNotContain-after-reset fails if the pre-world declaration is
reintroduced (the coordinator's RED-verification). Binary confirmed
post-fix (new tests reference RedeclareAfterWorldEntry); 3/3 new + 58/58
touched classes green. 13,286/4/0 reconciles (+1, 0 deletions).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The FA4 fix round's MUST-FIX 3 placed the 0x00A6 reconnect re-arm at the
wrong lifecycle point (re-review 8bbceff5): ResetSessionTransientUi runs
via the SessionDialogs reset stage BEFORE _inWorld=true, so SetPanelOpen
(world-gated, Validate requireWorld:true) returned Inactive and published
nothing — yet _pageVisible was latched true anyway, so no later hook
re-declared and fellow vitals stayed frozen for the whole new session.
The unit test passed only because the fake recorded unconditionally.
Two-part fix, both retail-faithful mechanisms not suppressions:
- SocialFellowshipPageController.SetPageVisible advances the edge-trigger
latch ONLY when the declaration is Accepted (published), so a dropped
pre-world send leaves the latch clear and a later attempt retries.
- ResetSessionDeclaration (pre-world) now ONLY clears the latch; the new
RedeclareAfterWorldEntry fires from the LiveSession EnteredWorld seam
(wired via RestoreLayout, idempotent if a persisted layout already
re-showed the page) so a still-open Fellowship page re-declares 0x00A6
in world and vitals resume.
Regression pins that actually catch it (the prior test could not):
- SetPageVisible_DoesNotLatch_WhenDeclarationDropped_SoItRetriesInWorld
(widget-level root, world-gated fake);
- Reconnect_ReDeclares0x00A6_AfterWorldEntry_NotDuringPreWorldReset +
Reconnect_StaysSilent_WhenFellowshipPageIsNotActuallyOpen (panel-level,
world-gated). RED-verified: reintroducing the pre-world declaration
fails the reconnect test.
Full Release suite: 13,286 passed / 4 skipped / 0 failed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Re-derived each disposition from the actual fix diffs (290f9b58/5499f058/
df000306/300d8189/55b17e15/1d743277/f041b09b), not the commit claims.
CLOSED (4/5 MUST-FIX, all 9 SHOULD-FIX, all 4 NIT, blast SF-1):
- MUST-FIX 1: (int)((double)pct*100.0) truncation + 6->44%/8->34% pinning
cases + gate step corrected.
- MUST-FIX 2: intercept deleted, every type routes to the generic
controller, type-4 dialog test added; type-1 allegiance path unaffected
(was never intercepted).
- MUST-FIX 4: world->panel selection sync reproduces retail's found/
fallback arms; AD-82 records the deferred generic UiTemplateListBox
selection-model port honestly -- minimal-observable-contract, not a
hidden gap.
- MUST-FIX 5: AD-82/AD-83 well-formed; AD-78 count corrected to 34/16.
- D6/D7/SF-8 dimming (audited from source): 34 dimmed / 16 live is
correct, not split-the-difference. FellowshipShareLoot has NO client
value-reader (only an editor/display surface; 0x00A2 sends shareXP
alone; ACE authors loot server-side) -> dimmed faithful.
FellowshipShareXP is genuinely read by the Create click -> Live right.
REOPEN (MUST-FIX 3): the 0x00A6 reconnect re-arm is placed at a pre-world
reset seam. ResetSessionTransientUi runs via the SessionDialogs reset
stage at ResetHostBeforeStart / retired-scope teardown -- both BEFORE
_inWorld=true and before command activation for the new generation -- and
SetPanelOpen requires world, so the re-declaration returns Inactive and
nothing is published, yet _pageVisible is still set true and no
post-world-entry hook re-evaluates. The new server never receives 0x00A6
and fellow vitals stay frozen -- the exact bug the fix targets. The unit
test passes only because its fake command records unconditionally.
Recommend moving the re-declaration to an in-world seam (EnteredWorld).
Totals/probe: 109/109 touched App test classes green on post-fix
binaries; live-mount probe PASS 1/1; +13/0-deletion delta and 13,285/4/0
corroborated on the touched projects and by arithmetic (not re-run
end-to-end).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
FA4 row now names all six fix-round commits (290f9b58, 5499f058, df000306,
300d8189, 55b17e15, 1d743277) alongside the original landing's three, and
records: build/test green at every commit; the +13/0-deletion test delta
broken down per file; the final directly-measured 13,285 passed / 4
skipped / 0 failed (13,289 total); every MUST-FIX/SHOULD-FIX/NIT applied;
and the corrected dimmed-row arithmetic (35 -> 31 FA4-original -> 34
fix-round final, net one row). Also corrects item (4) of the
"contradictions/deferrals" list, which called the missing Recruit
is-a-player register row an acceptable inline comment -- MUST-FIX 5 named
that the wrong call; it is now register row AD-83.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Register (docs/architecture/retail-divergence-register.md):
- AD-78: the Character-tab dimmed count had drifted stale through two
campaigns (still read "35" after FA4 shipped 31; now 34 after the fix
round's three reversions). Addendum explains the full D6/SF-8 chain.
Blast review's own SHOULD-FIX 1.
- AD-82 (new): the invented leader-tint/selection-tint colors, the
name-text-only row click target, and the page-local (not generic
UiTemplateListBox) world->panel selection sync -- MUST-FIX 4's
disposition plus two items MUST-FIX 5 named as owed rows.
- AD-83 (new): the Recruit button's missing "target is a player" gate,
previously an inline comment, not a register row -- MUST-FIX 5's third
item. Section header bumped 61 -> 63 active rows.
Gate script (docs/research/2026-08-12-campaign-fa-test-script.md):
- SF-7: fixed step 3's self-contradiction ("only Quit" then "Disband and
Open should ALSO be enabled").
- MUST-FIX 3: new reconnect step after the existing close/reopen step.
- MUST-FIX 4: new world-selection step under the recruit/dismiss/quit
section.
- MUST-FIX 1: new HARD-check step for the 6/8-fellow 44%/34% truncation
(distinct from the existing SOFT 9-member ACE-divergence note).
- MUST-FIX 2 correction: the old invite steps tested whether acdream's
CLIENT gates the dialog on the option bits -- a mechanism that never
existed in retail and no longer exists in acdream. Rewritten to test
the corrected behavior (the dialog always shows regardless of the
target's own checkbox state) and to explain what ACE-side filtering
would look like if the local server implements it, so a tester doesn't
misattribute ACE's behavior to a client bug.
- Renumbered steps 9-22 to 9-25 to fit the two new steps; updated the
"what to report" section's step cross-references and rewrote its
invite/dimming bullets to match the corrected mechanism.
Plan (docs/plans/2026-08-11-fellowship-allegiance-campaign.md):
- D7 addendum: SF-8's further correction (FellowshipShareLoot reverts
too; only FellowshipShareXP survives as genuinely live).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
SocialPanelLiveMountProbeTests wrote the four checkbox labels and the two
Open/Close captions to the console with no assertion, yet the FA4 ledger's
live-DAT paragraph cited them as verified -- the same finding FA3's own
mechanism SF-3 raised for a different table ("printed but never asserted
-- deserves a real assertion, not just a hope"). Now asserts each label is
non-null/non-empty and the two captions equal the exact retail strings
"Open"/"Close". Env-gated (ACDREAM_PROBE_LIVE_MOUNT=1, real installed
DATs) -- inert in this session's build/test run.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The corrected plan D6 (docs/plans/2026-08-11-fellowship-allegiance-campaign.md)
established that retail's client reads neither IgnoreFellowshipRequests nor
FellowshipAutoAcceptRequests on the fellowship-invite path -- both are pure
server-side filters with no client consumer, exactly like the two
allegiance bits they were always meant to parallel. Their claimed consumer
(RetailUiRuntime.TryAutoRespondToFellowshipInvite) is deleted in a sibling
commit this fix round. Both rows revert from Live to StoreOnly.
Mechanism review SF-8 additionally found FellowshipShareLoot's claimed
consumer -- "a second live checkbox surface on the fellowship page" -- is
not a consumer at all: nothing in acdream reads the stored value back
(FormatStatsText uses snapshot.ShareXp only; the 0x00A2 Create builder
carries shareXP alone), and the live-DAT dump confirms its checkbox is a
child of the NOT-in-fellowship frame -- invisible whenever you actually
have a fellowship to loot-share within. A second EDITOR of a value is not
a CONSUMER of it under AD-78's own "drives nothing observable client-side"
definition. FellowshipShareLoot reverts too.
Only FellowshipShareXP survives as genuinely live -- the Create-flow click
reads it directly as the sent shareXP wire bit. Net: 35 (pre-FA4) -> FA4
shipped 31 -> fix round reverts three -> 34 of 50 dimmed / 16 live, ONE
net un-dim from the pre-FA4 baseline, not four. Updated the class doc's
derivation table, the conformance test's ExpectedStoreOnlyIds set, and the
31/19 count assertions to 34/16.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
MUST-FIX 3 -- SocialFellowshipPageController.SetPageVisible is edge-triggered
on a bool that survives a generation reset unchanged while the panel stays
open, so a reconnect never re-sends 0x00A6 and fellow vitals freeze for the
rest of the new session. SocialPanelController.ResetSessionDeclaration
clears the fellowship page's latch (SocialFellowshipPageController.
ResetPageVisibleLatch, this commit's counterpart) and re-evaluates the
existing "window shown AND Fellowship active" conjunction, wired into
RetailUiRuntime.ResetSessionTransientUi -- a seam that already runs on
every generation reset. A still-open Fellowship page re-declares; a closed
or other-tab page correctly stays silent.
SF-4 -- SocialPanelController's constructor subscribed an anonymous lambda
to UiTabPanel.ActivePageChanged with no way to remove it; Dispose only set
a flag. A tab switch after Dispose still reached
UpdateFellowshipPageVisibility and issued a Runtime command, since Tick's
own _disposed guard doesn't cover this event path. Stored the handler as a
field and unsubscribe it in Dispose.
Also adds the panel-level D4 conjunction test mechanism SF-5 flagged as
missing (the only prior D4 test exercised the PAGE controller's own
edge-trigger directly, never SocialPanelController's "window shown AND
Fellowship active" logic or its ActivePageChanged subscription).
Per docs/research/2026-08-12-fa4-review-mechanism.md.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
MUST-FIX 1 -- D5's percentage conversion rounds where retail truncates.
gmFellowshipUI::UpdateFellowStats @0x0048ECC9 forms pct*100.0f on the x87
stack then calls _ftol2 (MSVC's round-to-truncate helper), never
MathF.Round. The stored floats for 6 and 8 fellows are 0.44999998807907104
and 0.3499999940395355 (byte-read from the PDB-paired binary), so retail's
own products truncate to 44/34, not 45/35 -- and (int)(pct*100f) alone does
not fix it, since 0.45f*100f already rounds UP to exactly 45.0f in single
precision. Fixed as (int)((double)pct * 100.0), forming the product the
same wider-than-single-precision way retail's x87 does. Pinned with new
[InlineData] cases for both sizes.
MUST-FIX 4 -- gmFellowshipUI::UpdateFellowSelection @0x0048F0F0 (the
world->panel arm of retail's two-directional selection coupling) was never
ported; only the panel->world arm (SelectFellow) shipped. Selecting a
fellow in the WORLD left Dismiss/Assign-Leader disabled and showed no row
highlight. SyncSelectionFromWorld/SetSelectedFellow reproduce the
observable contract (button-enable + a row tint) against this
controller's own guid-keyed row dictionary instead of porting retail's
generic ListBox SetAttribute_InstanceID/SetSelectedItem primitive (scoped
disposition recorded at register row AD-82).
Also in this pass over the controller:
- SF-1: cache the fellowship-name LinesProvider; only reassign on an
actual name change (was allocating once per Tick, even while hidden).
- SF-2/SF-3: track true membership in _memberGuids, independent of which
rows finished building. Fixes an unbounded DAT-locked rebuild retry
when a row template permanently fails to build, and fixes Recruit's
"already a fellow" check reading render rows instead of membership.
- N-0: the Open/Close caption now flips optimistically on click, matching
retail's pre-toggle-before-server-echo (lane B feature 11).
- N-1/N-2/N-3: doc-only notes on the meter-child-text gap, the max>0
guard, and Tick's two-read non-atomicity.
- ResetPageVisibleLatch: the fellowship-controller half of MUST-FIX 3
(see the SocialPanelController commit for the panel-level half).
Per docs/research/2026-08-12-fa4-review-mechanism.md.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
RetailUiRuntime.TryAutoRespondToFellowshipInvite auto-declined/auto-accepted
fellowship invites based on IgnoreFellowshipRequests/FellowshipAutoAcceptRequests
before the dialog ever reached GameplayConfirmationController. Byte-verified
across Handle_Character__ConfirmationRequest @0x005640A0,
RecvNotice_FellowshipRequest @0x00490880, and MakeFellowRequestDialog
@0x00490620 (whose only guard is m_fellowRequestContext) plus a whole-file
sweep of both option accessors: retail's client reads neither bit on any
confirmation path. ACE filters both bits server-side, so the interceptor was
dead code against a correct ACE and actively harmful against a drifting one
(IgnoreFellowshipRequests defaults true, so it would silently swallow real
invites with no dialog and no chat line).
HandleConfirmationRequest now routes every confirmation type, including 4,
straight to the generic controller -- exactly like retail. No tests existed
for the deleted interceptor (nothing to remove); added a test proving the
type-4 dialog renders the server message verbatim (not "Continue?"-suffixed)
and sends accept/decline through the generic path.
Per the corrected plan D6 (docs/plans/2026-08-11-fellowship-allegiance-campaign.md).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
D6 asserted the client consumes IgnoreFellowshipRequests/
FellowshipAutoAcceptRequests on the invite path; the FA4 mechanism review
(913e35cd MUST-FIX 2) byte-verified retail reads NEITHER bit client-side
(Handle_Character__ConfirmationRequest @0x005640A0, RecvNotice_
FellowshipRequest @0x00490880, MakeFellowRequestDialog @0x00490620) — ACE
filters both server-side. Same class as the D2 reset-lifetime correction.
D6/D7 corrected in-place with dated addenda: the client-side intercept is
removed, the invite dialog always shows, and the two un-dims revert
(dimmed 31->33). D8 records the user-provided second account
(testaccount2/testpassword2) and the recruit-proximity requirement.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A second mechanism-lens review landed on the same path at 6849b457 while
this one was in progress and was overwritten by 913e35cd. Its text is
recoverable from git and is now cited from a new appendix, its two unique
findings are carried forward, and the two places the reviews disagree are
adjudicated from primary source.
Carried forward:
- SF-9: AD-78's register row still says "35 of 50 rows dimmed" (the D7
addendum landed in the class doc, not the row's Where column).
- N-0: the Open/Close caption does not optimistically pre-toggle; lane B
feature 11 records that retail's handler pre-toggles _open_fellow
locally before sending 0x0291.
Adjudicated:
- _ftol2 vs MathF.Round: 6849b457 filed it a NIT ("round and truncation
agree on every table value"). That holds for the DECIMAL literals, not
the stored floats -- 0x007C91D4 = 0.44999998807907104 and 0x007E72BC =
0.3499999940395355, so retail truncates 44.999998/34.999999 to 44/34
while acdream rounds to 45/35. Stays MUST-FIX 1.
- D6 invite auto-response: 6849b457 passed it as verified-clean after
confirming the code matches the plan. The binary says retail has no
such client-side read on any confirmation path. Stays MUST-FIX 2.
The reviews agree on the reconnect D4 hole, the missing panel-level D4
conjunction test, the leader-tint register omission, and the live-DAT
probe result.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Findings persisted before any fixer dispatch, per the campaign's §7
review protocol.
MUST-FIX, in the order they were derived:
1. D5's percentage conversion rounds where retail truncates. Byte-decoded
0x0048ECC9..0x0048ECD8 (fld pct; fmul [0x007A5170]=100.0f; call
_ftol2 @0x005DE394 -- the fld/fst/fistp/fild truncation dance), so a
6-fellow roster displays 45% where retail shows 44%, and 8 fellows
shows 35% vs 34%. The table itself IS byte-exact; only the ->int
conversion diverges, and it is not fixable by a plain cast because
0.45f*100f already rounds up to 45.0f in single precision.
2. D6's client-side invite intercept has no retail anchor. Read in full:
Handle_Character__ConfirmationRequest @0x005640A0 (bare jump table),
RecvNotice_FellowshipRequest @0x00490880, MakeFellowRequestDialog
@0x00490620 (only guard is m_fellowRequestContext), plus a whole-file
sweep of both option accessors -- zero reads on any confirmation path.
The code comment cites ACE's Fellowship.cs as "retail". ACE filters
both bits server-side, so the intercept is dead against a correct
server and harmful against a drifting one -- and IgnoreFellowshipRequests
defaults to TRUE client-side.
3. D4 never re-declares 0x00A6 after a generation reset: the edge-
triggered _pageVisible latch survives reconnect, so fellow vitals stay
frozen for the whole new session. ResetSessionTransientUi is the seam.
4. gmFellowshipUI::UpdateFellowSelection @0x0048F0F0 is not ported --
selecting a fellow in the WORLD leaves Dismiss/Leader disabled and no
row ever shows selected; the plan's contracted UiTemplateListBox
selection model + 0x1000000D row instance-id were not added.
5. Three shipped deviations have no register row (invite intercept, gold
leader tint, name-text-only row selection); the Recruit is-a-player
gate's "inline comment, not a register row" call is also wrong.
Re-derived rather than trusted: the live-mount probe was re-run against
the installed DATs (every ledger element/string claim CONFIRMED, Bind()
warning-free), the GetEvenSplitXPPctg table was byte-read from the
PDB-paired binary, FlushPreservingScroll's shrink semantics were traced
through UiScrollablePanel/UiScrollable (sound), and the five touched
test classes pass 93/93.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Mechanism-faithfulness lens on 357d2032/5bdd0528/38f08314. Every
wire-touching mechanism verifies retail-faithful: the D4 0x00A6 gate
(all five in-session transitions + idempotence + no-send-while-
disconnected), the leader-quit 0x0290-before-0x00A3 hand-off routing,
the D5 byte-exact even-split table, the D6 type-4 auto-response +
Runtime mutual exclusion, the D7 four-row un-dim (35->31 conformance),
scroll preservation across a rebuild, create-flow refusal-by-enabled-
state, and the button-enable rules. Live-mount probe PASSES 1/1 against
real DATs; FA4 suites 75/75 App + 28/28 Runtime under --no-build.
MUST-FIX: the leader-gold-tint adaptation has no divergence-register row
(AD-80/AD-81 don't cover it). SHOULD-FIX: AD-78's stale 35-of-50 count;
D4 not re-armed across a reconnect while the panel stays open; no
panel-level test pins the D4 conjunction. NITs: caption pre-toggle,
_ftol2-vs-Round (both non-blocking).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
All nine blast axes verified clean at the code level. Single fix:
the AD-78 register row still reads "35 of 50 rows dimmed" after FA4's
D7 flipped four rows to Live (now 31 of 50) -- the class doc and
conformance test were updated, the binding register row was not.
Plus one minor non-blocking observation on GetMembers' per-vitals-tick
allocation profile.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Register: AD-80 files the D5 XP-share display divergence between
retail's byte-decoded table (acdream renders it verbatim) and the
currently-targeted ACE server's slightly different actual grant (.3 vs
.3111111 at 9 fellows, no 10-fellow row, wrong out-of-range default) --
an ACE-vs-retail gap, not an acdream-vs-retail one, filed because it is
directly user-visible through this panel. AD-81 files the two unported
retail text-composition primitives the fellowship page's mechanism
needs (StringInfo variable substitution, ACCharGenData::FormatName) and
what acdream renders instead (plain numeric composites, the raw typed
name). AD-78's derivation table gains its D7 addendum: 4 of the 35
store-only rows (IgnoreFellowshipRequests/FellowshipAutoAcceptRequests/
FellowshipShareXP/FellowshipShareLoot) moved to the Live bullet with
their new consumers named.
Gate script: new §FA4 section covering create (name + shareXP), the
open/close caption swap, button-enable rules, and the D5 display -- all
solo-testable -- plus roster/recruit/dismiss/leader-handoff/invite-
dialog steps marked [TWO-CLIENT] with an honest note that they defer to
FA6's bot-vs-ACE gate if a second account isn't available for this
connected gate. Corrects FA3's now-stale "these six buttons/four
checkboxes are INERT" claims in steps 11-12 to point at the new
section instead of leaving a wrong claim in place.
Ledger: FA4 row CODE-COMPLETE with both commit SHAs, the reconciled
13,238->13,272 (+34) test-count arithmetic, the live-DAT verification
summary (ACDREAM_PROBE_LIVE_MOUNT=1 against real installed DATs,
including the structural finding that retail's own frame-visibility
swap already gates the Create-flow controls away from the roster view
with no extra code needed), and the four scoped
deferrals/simplifications this slice made (the StringInfo/FormatName
gap, the proportional-share omission, the Recruit button's
superset-of-retail enable rule).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Roster: SocialFellowshipPageController now builds one row per fellow
from the authored template (0x21000030/0x10000281, live-DAT verified),
diffing the member GUID set on each revision-gated Tick -- an unchanged
set updates every row's bound widgets in place (no ListBox mutation, so
scroll position is untouched by construction); only a real join/leave/
disband triggers a rebuild, via UiTemplateListBox.FlushPreservingScroll
(FA3 carry-forward 1, both the widget-level fix and the controller-level
diff). Health/stamina/mana meters bind Fill+Label; the leader's name
tints gold (lane A's row template has no dedicated leader marker, so
this is a flagged adaptation, not a ported mechanism). Row-click
selection (SelectFellow) drives Dismiss/Leader targeting and the world
selection (SelectionChangeSource.Social).
D4: SocialPanelController now tracks "is the social window shown AND is
Fellowship the active tab" via UiTabPanel.ActivePageChanged +
OnShown/OnHidden, and calls SetPageVisible on every transition, which
sends 0x00A6 (idempotent, no-op while disconnected) -- the prerequisite
ACE gates its 0x02C0 vitals stream on.
Create flow: the inline name field (0x1000026F, an authored Editable
UiField -- live-DAT verified) gates the Create button's enabled state
exactly like retail (empty name = disabled = the whole refusal
mechanism, no separate error text); FellowshipShareXP's live value is
read at click time.
Actions + confirmations: Recruit/Dismiss/Quit/Disband/AssignLeader/
SetOpen all route through DeferredGameRuntimeStateCommands (new
Fellowship* methods) rather than a raw WorldSession send, so Quit
correctly picks up RuntimeFellowshipState's leader hand-off rule.
Button enable states port gmFellowshipUI::UpdateButtons verbatim. The
Open/Close button's caption swaps between the two DAT-resolved strings
cached once at Bind (never per-tick -- DatCollection is not safe to
touch unprotected from the render loop). RetailUiRuntime intercepts a
type-4 confirmation request before it reaches the generic
GameplayConfirmationController: IgnoreFellowshipRequests auto-declines,
FellowshipAutoAcceptRequests auto-accepts, neither set falls through to
the existing dialog machinery unchanged (D6).
D5 display: the per-fellow stats line uses retail's byte-decoded
even-split percentage table verbatim (1.0/.../.3111111/.28, default
0.0); the proportional branch omits the percentage rather than
inventing a formula (no acdream ExperienceToRaiseLevel table exists
yet). Both StringInfo variable substitution (row/stats/vitals text) and
ACCharGenData::FormatName (create-flow name canonicalization) are
unported prerequisites, so row text renders as plain numeric composites
-- register rows AD-80/AD-81 (docs commit).
D7: un-dims IgnoreFellowshipRequests/FellowshipAutoAcceptRequests
(consumed by the D6 auto-decline/accept) and FellowshipShareXP/
FellowshipShareLoot (consumed by Create + the page's own second
checkbox surface) on the Character tab -- 4 of 35 store-only rows
promoted to Live (31 remain dimmed).
Carry-forwards from the FA3 re-review, folded into this slice's
contract:
- UiTemplateListBox.FlushPreservingScroll -- preserves scroll offset
across a rebuild instead of resetting to 0 (Flush's existing
contract, unchanged, for Friends/Squelch).
- RowTemplateResolver -- the FA3 caching row-template resolver
extracted from a MountSocialPanel local function into its own
hermetically-testable class; now shared by Friends/Squelch/
Fellowship's row families.
- Friends/Squelch scrollbars now resolve via the built
UiTemplateListBox.ScrollbarElementId (DAT property 0x72) instead of
a hardcoded literal, matching ConfigOptionsPageController's own OP6
precedent.
- The Fellowship roster path never advances its revision latch on a
partial resolver failure until the NEXT real membership change --
never a per-frame retry loop.
Live-DAT verified (ACDREAM_PROBE_LIVE_MOUNT=1, extended
SocialPanelLiveMountProbeTests): the name field builds as UiField, all
11 buttons/checkboxes resolve, the row template's 5 checked fields
resolve to the right widget types, every checkbox label/tooltip and the
Open/Close captions resolve to real retail strings ("Open"/"Close"),
and a full production-path Bind() against live DATs produces zero
"not found" warnings.
App tests: +30 (7 UiTemplateListBox/RowTemplateResolver unit tests, 23
SocialFellowshipPageControllerTests covering roster diff/rebuild,
button enable rules, checkbox wiring, create-flow gating, D4
idempotency, and D5 formatting) plus 2 CharacterOptionsPageController
counts updated for the D7 un-dim (35->31 dimmed, 15->19 live).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
IRuntimeFellowshipView.GetMembers() gives the fellowship panel a way to
enumerate the whole roster (TryGetMember alone needs the guid first,
which a UI roster build doesn't have yet). Implemented on
RuntimeFellowshipState.FellowshipView as a materialized snapshot under
the same lock every other read there uses.
SelectionChangeSource.Social covers a fellowship-roster row click
(gmFellowshipUI's list-selection arm calls the same
ACCWeenieObject::SetSelectedObject primitive every other selection
origin uses -- lane B docs/research/2026-08-11-fa-panel-structure.md
§6.2/§2.8).
Runtime tests: +4 (RuntimeFellowshipStateTests.GetMembers_*).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Narrow re-review of the FA3 fix round (9afa05b5/35c40a9b/ae772709/
a5553904/c5f73744) against this doc's 2 MUST-FIX and 9 SHOULD-FIX. Every
disposition re-derived from the diffs, not from the commit messages. All
11 correctly applied; nothing reopened; no new MUST-FIX or SHOULD-FIX.
Dispositions worth naming: MF-1/MF-2 were fixed as script REWRITES that
state the verified truth (Allegiance is both the default and the
left-most tab; @allegiance info prints chat data AND the blocks stay
hidden, report neither) rather than deletions, and MF-2's report-bullet
is correctly inverted to "blocks becoming VISIBLE is the anomaly".
SF-3's assertions pin more than asked (which page is visible, not just
that one is). SF-7 landed as a binding FA5 acceptance line plus the
forward hazard about Tick()'s unconditional LinesProvider reassignment,
the right altitude for a shell slice.
Claims verified rather than accepted: SF-1's no-register-row precedent
(grepped -- no row exists for any Toggle*Panel close-on-second-press, so
the precedent is real); the #383 timestamp correction (b4edee97
2026-08-11 09:19, e71e5a96 06:25, 74c3d85d 2026-08-12 02:58 = ~17h39m
and ~20h33m, previous day); OnShown/OnHidden really are driven by
RetailWindowHandle.NotifyVisibility and correctly do NOT fire for a
window mounted Visible=false; UiTemplateListBox.Scroll forces the
extent-seeded viewport so pre-row scrollbar wiring is sound; and the new
template cache is safe because Build is the pure builder -- only
BuildFromInfos (tests-only) mutates the ElementInfo it is handed.
Gates re-run on the post-fix Release binaries: live-mount probe passes
against the installed DATs with the promoted assertions showing
Allegiance Visible=True (other three False) and 0x10000492 count = 2;
AcDream.App.Tests 4,876 passed / 3 skipped / 0 failed, exactly +5 over
the pre-fix 4,871 and exactly the ledger's App figure, so the 13,238/4/0
+5 reconciles at the only project this round touched. Blast radius is 14
files, all FA3's own plus doc-only edits to UiTemplateListBox and
MountSocialPanel.
Carry-forwards (non-blocking): the Flush scroll-position reset will bite
harder in FA4's per-vitals-tick roster rebuild; the production template
cache has no test (both long-roster tests use the fake resolver); the
scrollbar element ids are literals where ScrollbarElementId carries the
authored value (cohort-wide nit); and the allRowsResolved retry rebuilds
per frame on a permanently unresolvable template (bounded and cheap).
FA3's remaining obligation is unchanged: the user's connected gate,
against a script that no longer contains two instructions guaranteed to
produce false defect reports.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Records the FA3 fix-round closure: both dual-lens reviews' findings
(mechanism 2 MUST-FIX/9 SHOULD-FIX, blast 1 MUST-FIX/6 SHOULD-FIX/1 NIT)
applied across four commits (9afa05b5/35c40a9b/ae772709/a5553904).
Baseline 13,233/4/0 (13,237 total) -> fix-round 13,238/4/0 (13,242
total), +5 tests, arithmetic verified directly against all 9 test
project totals. FA3 still owes the user's connected gate against the
now-corrected script.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Mechanism MUST-FIX 1: the connected-gate script's steps 1/9/10 carried
the REFUTED x-order guess forward — claiming Friends was drawn left-most
and that the authored default (Allegiance) was somehow NOT the left-most
tab. The real authored order (fixture + live-mount probe, corroborated
by each page's own P0x57 action-map id) is Allegiance (x=0, DEFAULT),
Fellowship, Friends, Squelch — the default tab IS the left-most tab.
Fixed steps 1, 9, 10, and the "What to report" bullet that repeated the
wrong claim.
Mechanism MUST-FIX 2: step 14 sent the user to `@allegiance info` as a
trigger that would supposedly reveal the monarch/patron blocks, and told
them to report it if it didn't — the trigger CANNOT fire post-FA2
(0x0020 AllegianceUpdate is the only inbound writer of this panel's
data; 0x027C, the @allegiance info response, stopped seeding it in
4272ad0e) and FA3 sends no 0x001F subscription at all (FA5 scope). The
script primed the user to file a false defect. Rewritten to state the
true FA3 expectation: @allegiance info prints real data to chat, the
panel blocks stay hidden regardless, for the whole gate — report
NEITHER half as a bug; the actual anomaly to watch for is the blocks
becoming visible at all.
Mechanism SHOULD-FIX 4: step 11's Fellowship checkbox count hedge
("three... a fourth may also be present") replaced with the settled
count (four).
Blast NIT 8 / gate note: added two steps the original script never
exercised — a long-roster Friends/Squelch scroll check (exactly where
blast MF-1's scrollbar-wiring fix bites, and a short test roster would
never surface it) and an honest restore-open-across-relaunch
observation step (the social panel follows the SAME restore-open
convention every sibling main panel already has — Options/Spellbook/
Character/Inventory/Vitae — stated up front so it isn't mistaken for a
bug mid-gate).
Blast SHOULD-FIX 6: docs/ISSUES.md #383 said the two drifted fixtures
were committed "days ago" — git says otherwise: ~18h and ~21h before the
FA3 regeneration run, the previous day. Corrected, and added the
mechanism reviewer's no-drift finding for the NEW social-panel fixture
(cross-checked against the live probe on every axis, zero drift) —
narrows the issue to exactly the two pre-existing OP-era fixtures.
Blast SHOULD-FIX 7: the §10 addendum in fa-panel-structure.md had five
`**` bold markers (odd count) — an orphaned trailing marker bled bold
formatting into the following section. Dropped the orphan; the addendum
now bolds only its lead sentence and the inline "Allegiance" callout,
both balanced pairs.
Mechanism SHOULD-FIX 1 (research-doc half): filed unknown U11 in §8 —
what a repeat F3/F4 press does when the panel is open on the OTHER tab
is not established from retail decomp (no OnAction consumer exists for
either action in the binary); acdream's own OpenSpellbook-precedent
choice is not a retail port.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Mechanism SHOULD-FIX 4: SocialFellowshipPageController's class doc said
0x1000026B holds "all three visible option checkboxes" — the fixture and
the decomp both authors FOUR (Ignore Fellowship Requests / Auto-Accept
Requests / Share XP / Share Loot, 0x10000270-0x10000273). Corrected.
Mechanism SHOULD-FIX 9: SocialPanelRowText.FindDeepest's doc promised
"the deepest UiText descendant" but the implementation returns the LAST
match in pre-order traversal order, which only equals the deepest when
the subtree is a single chain. Both real row templates ARE single
chains today, so behavior is unaffected — the doc now describes what the
code actually does instead of a stronger guarantee it doesn't implement.
Blast SHOULD-FIX 5: UiTemplateListBox.Flush()'s doc only mentioned the
ContentHeight reset; UiScrollablePanel.ClearContent() also resets scroll
position to 0, which the sibling UiItemList.Flush() (same method name,
different semantics) does NOT do. Documented explicitly, including the
UX cost this creates for a scrolled-in Friends/Squelch roster once its
scrollbar is wired (this fix round's blast MF-1) — flagged for whoever
revisits Friends/Squelch scrolling next rather than silently fixed as an
unasked behavior change.
Mechanism SHOULD-FIX 3: SocialPanelLiveMountProbeTests printed two
headline findings (the 0x10000492-authored-twice count, page exclusivity
after ActivateTabBehavior) without ever asserting them — a future
importer regression collapsing/dropping an instance, or breaking
exclusivity, could not fail this test. Both are now real assertions
(Assert.Equal(2, passupCount); exactly one page Visible and it is
Allegiance).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Mechanism SHOULD-FIX 2 / blast SHOULD-FIX 4 (same finding, both reviews):
SocialAllegiancePageController.Tick() closed over a local `lines` on
every call — `() => lines` allocated a display class plus a delegate on
EVERY frame, unconditionally, at ~2,000 allocations/second at the
profile's measured FPS, in a hot loop the Modern Runtime slices spent
whole commits driving to 0 B/frame. Hoisted two static readonly
Func<IReadOnlyList<UiText.Line>> providers (BlankLineProvider/
NoLinesProvider); Tick() now assigns the cached delegate reference —
zero allocation while idle or active.
Mechanism SHOULD-FIX 7: FA3's empty-state gate is coarser than the
retail mechanism it is contracted against — gmAllegianceUI::UpdateMonarchData
@0x00491B40 hides the monarch/patron blocks per-relationship (monarch
block also hides when the monarch IS the viewer; patron block on the
analogous test), while this shell gates both blocks on the single
HasProfile flag. Not a MUST-FIX for FA3 (MF-2 in the same review means
HasProfile is effectively always false for the whole FA3 gate, so
nothing wrong is visible during this slice's own gate) — recorded
instead as an explicit FA5 acceptance line in the plan's FA5 row so the
gap cannot be silently lost, plus a class-doc note that FA5's real
monarch/patron population must also change Tick()'s unconditional
LinesProvider reassignment in the same commit or its content will be
overwritten the next frame.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Campaign FA slice FA3 dual-lens review fix round.
Blast MUST-FIX 1: SocialFriendsPageController/SocialSquelchPageController
never wired their ListBox's own sibling scrollbar (Friends
0x10000517->0x10000518, Squelch 0x1000053E->0x10000543) — the two lists
had NO scroll driver at all (no wheel fallback exists on
UiScrollablePanel), making any roster past the visible extent completely
unreachable, not just awkward to scroll. Both controllers now wire
`scrollbar.Model = listBox.Scroll` scoped to their own page root, exactly
like the four existing UiTemplateListBox consumers
(Character/Chat/Config/KeyboardConfig). New tests prove the wiring AND
that a >panel-height roster is actually reachable through it.
Blast SHOULD-FIX 2/3: the Friends/Squelch resolver re-ran
LayoutImporter.ImportInfos (a full DAT tree walk) under the shared DAT
lock on EVERY row, every revision, even while the panel was closed —
RetailUiRuntime.MountSocialPanel's TemplateResolver now caches each row
template's ElementInfo the first time it is resolved and never
re-Imports for that template id again. SocialPanelController additionally
gates the Friends/Squelch Tick-driven rebuild on the panel's own
visibility via the (previously unused) IRetainedPanelController
OnShown/OnHidden hooks, so no DAT-locked rebuild work runs at all while
the panel is closed. SocialFriendsPageController/SocialSquelchPageController
also now only advance _lastRevision after every row resolves — a
transient resolver miss no longer latches an empty roster until the next
server-side change; it retries on the next Tick instead.
Mechanism SHOULD-FIX 8: SocialPanelController.Tick() now returns early
once disposed, matching every other J-slice teardown discipline (it was
being ticked unconditionally forever since RetailUiRuntime never nulls
the field).
Mechanism SHOULD-FIX 1: IsShowingAllegiance's doc claimed the F3/F4
close-on-second-press semantics were "retail's Toggle-action semantics" —
re-derived and confirmed NO retail OnAction consumer exists for either
action anywhere in the binary. Relabeled as acdream's own
OpenSpellbook-precedent convention; no register row added, following the
same no-row precedent OpenSpellbook and every other non-toolbar Toggle
panel already sets. Unknown filed as U11 in the panel-structure research
doc's §8 table.
Tests: 5 new (2 scrollbar-wiring pins, 2 long-roster-reachable-via-
scrollbar, 1 hidden-panel-does-not-rebuild/shown-panel-catches-up); 2
existing tests updated for the new visibility gate.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Campaign FA slice FA3 mechanism-faithfulness review of 0a9ca2f1 /
74c3d85d / b6a25110 / d7e1cffd.
The load-bearing tab-table correction VERIFIED CORRECT three independent
ways: the authored 0x2E array re-parsed straight out of the committed
fixture (Allegiance 0x1000028C -> 0x10000291 is the sole IsDefault entry),
each button's caption resolved from the installed DATs via the live-mount
probe, and each page's own P0x57 + RegisterElementClass type re-read in
the pseudo-C. A fourth corroboration the addendum missed: under the
corrected pairing the default tab is also the LEFT-MOST tab (x=0).
MUST-FIX (both in the user's connected-gate script, neither code):
1. The script still carries the REFUTED x-order -- step 9 states the strip
as "Friends, Allegiance, Fellowship, Squelch" and step 1 primes the user
to expect Friends left-most. Real geometry: Allegiance x=0, Fellowship
x=72, Friends x=144, Squelch x=206.
2. Step 14 tells the user `@allegiance info` should reveal the
monarch/patron blocks and to "report if they do not" -- FA2's own fix
round deliberately stopped 0x027C from seeding RuntimeAllegianceState,
ApplyUpdate (0x0020) is the only writer of _hasProfile, and FA3 sends
no 0x001F. The script steers the user into a false defect report.
SHOULD-FIX: unsupported "retail's Toggle-action semantics" claim on F3/F4
(no P0x57 read site, no OnAction handler, folded gmPanelUI global-message
stub -- the rule is acdream's OpenSpellbook precedent, not retail);
per-frame closure allocation in SocialAllegiancePageController.Tick; two
probe findings printed but never asserted (0x10000492 count, page
exclusivity); "three checkboxes" is four; AD-79 enumerates seven controls
but its cited test pins six; the two page controllers do not name AD-79;
the allegiance empty state is gated on HasProfile rather than retail's
per-relationship rule (TryGetMonarch/TryGetPatron already exist);
Tick() ignores _disposed; FindDeepest's doc overstates its guarantee.
Verified clean: every §6 Campaign-OP lesson (string resolver on both
Builds, scoped lookups -- I enumerated ALL duplicate ids and found three
previously-uncalled-out cross-page repeats, tab activation, no 0x0-extent
lazy children, cross-layout templates so the same-layout skip cannot
apply, no hand-rolled viewport); U6 genuinely closed (page 0x10000292 has
exactly two children); Flush/ClearContent resets ContentHeight; the J4.1
owners are borrowed by reference and clear in place; catalog id 12
byte-verified and the toolbar seam tolerates it via the same path four
existing non-toolbar panels take; window-frame policy byte-identical to
Options. data_794358 BYTE-VERIFIED in the PDB-paired binary as UTF-16LE
" " (one space, not empty) -- lane A's L" " reading and FA3's BlankLine
both correct. Live-mount probe passes against the installed DATs with no
fixture drift; 22 FA3 tests and 4,871 App tests / 3 skips / 0 failures.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
MUST-FIX 1: the Friends/Squelch lists have NO scroll driver. Both
controllers set TemplateResolver but never wire the authored scrollbar's
Model (0x10000518 / 0x10000543, both direct siblings of their ListBox
under the page root per FA3's own fixture), unlike all four existing
UiTemplateListBox consumers. There is no wheel fallback -- wheel scroll
lives only on UiText, not UiScrollablePanel -- so Scroll has no driver at
all. Visible at authored size: the 400/430-tall ListBoxes sit at y=40 in
a 362-tall panel, so rows past ~322px are off-panel AND unreachable.
AD-79 covers the inert BUTTONS, not a dead scrollbar.
SHOULD-FIX: the revision-driven rebuild does N live DAT imports under the
shared DatLock while the panel is CLOSED (first repeating consumer of a
resolver every other caller invokes once at Bind); Refresh() consumes the
revision before building so one resolver miss latches an empty list;
per-frame closure allocation in the hidden allegiance page; Flush()'s doc
omits its scroll reset and diverges from the sibling UiItemList.Flush()
it shares a name with; #383's "days ago" is really ~18-21h per git;
the SS10 addendum's trailing ** is orphaned.
Verified clean: catalog/window-name consumers all degrade safely
(SetPanelOpen(12) is a no-op, opacity controller correctly scoped by
#379); persistence omission is the documented cohort behavior; F3/F4
plumbing predates FA3 entirely (UI.Abstractions untouched) and the
apparent bare-F3 duplicate is in the non-production AcdreamCurrentDefaults;
mount order respects the DialogFactory constraint; SocialRuntimeBindings
is required-positional with one construction site; the generator is
env-gated and only the new fixture landed; +18 reconciles exactly
(23 targeted + 289 blast-radius regression tests pass on the FA3 binary);
AD-79 well-formed with the count bumped 58->59; and the corrected tab
table was independently re-read from the fixture's own TabTable
(0x1000028C -> 0x10000291, IsDefault=true -- Allegiance IS the default).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
docs/research/2026-08-11-fa-panel-structure.md §10's coordinator
addendum inferred the social panel's button-to-page pairing from
authored x-order, landing on Friends as the implied default tab. The
FA3 fixture dump read the real authored 0x2E tab table: Allegiance
(button 0x1000028C) is the actual default, corroborated independently
by each page's own P0x57 lining up with the real F3/F4 ActionMap ids.
Same convention as the FA1/FA2 fix-round addenda already in this
campaign's docs -- correct in place, keep the original text visible.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- docs/research/2026-08-12-campaign-fa-test-script.md: the user's
connected-gate script for FA3 -- open paths (F3/F4 + tab switch),
gmPanelUI exclusivity vs sibling panels, tab switching, all four
pages' expected shells/empty states, Friends/Squelch read-only
expectations + the D1 INERT buttons, what to report, what's
explicitly out of scope (FA4/FA5/FA6/D1's deferred wire).
- Plan ledger: FA3 row filled in with commit SHAs, totals (13,233/4/0,
13,237 total, +18 over FA2's close), the tab-table correction finding,
and the reverted unrelated fixture drift note. Campaign status line
updated from "FA3 in flight" to implementation-complete pending
review + gate.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- RetailLayoutFixtureGenerator dumps LayoutDesc 0x2100006E slot
0x1000018F to the committed social_panel_2100006E_1000018F.json
fixture, closing lane-A unknowns U3/U4/U6/U7/U10 with real DAT data
(geometry/media/fonts/base refs, the fellowship empty/full frame
containment, the panel/page P0x57 properties, the row-template arrays).
- SocialPanelLiveMountProbeTests exercises the PRODUCTION mount path
against the live DATs (ACDREAM_PROBE_LIVE_MOUNT=1): the tab host
resolves as UiTabPanel with its 4-entry table, all four pages resolve,
the fellowship frame pair and allegiance signature elements resolve,
0x10000492 is confirmed authored twice under the allegiance page, and
every tab button caption is non-empty (the #375 resolver class).
- SocialPanelControllerTests pins the fixture-driven conformance: the
real (coordinator-addendum-correcting) tab table and default entry,
the Fellowship/Allegiance empty-state gates, Friends/Squelch row
population and revision-driven rebuilds, and the D1 INERT-button
contract (AD-79) via
FriendsAndSquelchActionButtons_AreClickable_ButHaveNoHandler.
- RetailPanelCatalogTests gains the SocialPanel id/window-name/
Mounted-not-Toolbar pins (lane A §6.1: no toolbar button).
Note: the ACDREAM_REGENERATE_UI_FIXTURES=1 run used to produce the new
fixture also touched keyboard_config_21000009.json and
options_2100002B.json on this machine (unrelated installed-DAT drift,
likely from local DAT-editing tooling) -- both were reverted to HEAD
before this commit; only the new social panel fixture is included.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Campaign FA slice FA3: retail's four-tab social panel (LayoutDesc
0x2100006E slot 0x1000018F, RetailPanelCatalog id 12), built on the OP3
OptionsPanelController recipe -- Type-8 tab host, ActivateTabBehavior,
per-page scoped controllers.
- SocialPanelController mounts the tab host and wires the close button;
F3 (ToggleAllegiancePanel) and F4 (ToggleFellowshipPanel) open the
panel and switch to their own tab, sharing the same gmPanelUI
one-active-panel exclusivity every sibling main panel already has.
- The live-DAT tab table CORRECTS the coordinator addendum's x-order
guess: button 0x1000028C ("Allegiance") pairs with page 0x10000291 and
is the authored DEFAULT entry, not Friends -- each button's own page
id and its own P0x57 (matching the F3/F4 ActionMap ids on the
Allegiance/Fellowship pages specifically) both corroborate the real
pairing. See SocialPanelController's class doc for the full table.
- SocialFellowshipPageController swaps the two authored empty/full
frames (0x1000026B/0x10000275) on RuntimeFellowshipState's
IsInFellowship -- both frames' full containment (name box, create
button, checkboxes vs. roster list, six buttons) was confirmed by the
live-mount probe, so a single Visible toggle per frame is the whole
swap (closes lane-A unknown U6).
- SocialAllegiancePageController hides the monarch/patron blocks and
blanks their name text to a literal space when
RuntimeAllegianceState.Snapshot.HasProfile is false, using SCOPED
FindDescendant lookups (the panel authors 0x10000492 twice, once per
block).
- SocialFriendsPageController/SocialSquelchPageController bind their
ListBoxes read-only to RuntimeCommunicationState's existing J4.1
Friends/Squelch owners (names only), rebuilding on revision change.
Their action buttons are honest INERT (D1) -- register row AD-79.
- UiTemplateListBox gains Flush() (lane A/D's "Gap found" prerequisite)
so a poll-and-rebuild list can shrink between refreshes.
- RetailPanelCatalog.SocialPanel = 12, byte-verified from the live slot's
own P0x10000029; listed in Mounted only (no toolbar button -- lane A
§6.1: the open path is keyboard-only).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The narrow re-review (cc1a319c) closed FA2 with no reopen; its
carry-forward corrected the FA5 row: the allegiance page's data
subscription is 0x001F AllegianceUpdateRequest(on) at retail's three
arming points, not the text-only 0x027B info request.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
All 2 MUST-FIX and all 6 SHOULD-FIX dispositions re-derived in the actual
fix-round diffs (4272ad0e, ded23067, ed8b3ec9); every blast disposition
spot-verified. Nothing skipped, no fix introduced a new mechanism defect.
MF-1: RuntimeGenerationResetStage.Allegiance added and drained, the owner
clears the profile AND drops HasServerSeed, the inverted test flipped. Stage
enumeration verified consistent everywhere -- every reference outside the
enum's own file is by NAME, nothing serializes the ordinal, so the +1 shift
is inert. MF-2: ApplyInfoResponseSelf, the delegate hole and the self-gate
are all gone (0 whole-tree hits); the test was rewritten to pin text-only
output for self and other guids alike.
SF-3's RecalculateEvenXPSplitting port checked line-for-line against lane B
2.10, including the deliberate leaderless-table departure -- lane B 7.4 says
verbatim "treat a leaderless table as leave _even_xp_split at 1", so the
citation is accurate. SF-4's 900s gate confirmed to have real data (FA1 does
parse 0x02BE field 8) and to gate only the new-guid branch. SF-1/2/5/6 all
land as specified.
Blast: the teardown table re-derived for every N in 0..13 (case 9 was
genuinely one flag over); the new reflection walk pins every intermediate
stage; seam-doc and plan addenda are dated and accurate; the corrected 11/10
counts are right. Audited blast SF-6's no-register-row conclusion and AGREE
-- clear-at-reset plus 0x0020-only seeding means acdream now matches retail,
so no deviation remains for a row to name.
Suite claim 13,201/4/0 -> 13,215/4/0 (+14) reproduced exactly by counting
discovered cases per file. Targeted post-fix Release runs: 95/95 Runtime,
67/67 Core.Net.
CF-1 (FA5, not a reopen): nothing re-subscribes 0x001F now that the reset
clears the owner, and the plan's FA5 row cites 0x027B for the panel-show
path -- which after MF-2 is text-only and feeds nothing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Plan (docs/plans/2026-08-11-fellowship-allegiance-campaign.md):
- D2 gets the D9-style dated strike/addendum recording the corrected
allegiance reset semantics (clears at every generation reset; the
HasServerSeed latch only gates pre-seed rendering WITHIN a session) with
the three-way evidence citation: the retail OnEndCharacterSession hook,
the RuntimeCharacterOptionsState precedent's actual clear-and-relatch
behavior, and the no-character-selector connect path
(SessionPlayerComposition.cs:1127).
- The architecture blurb and FA2's slice-map contract row get matching
strike/addendum corrections so the "fellowship session-scoped,
allegiance survives reconnect" claim does not survive uncorrected
anywhere in the plan.
- FA2's ledger row: fix-round commit SHAs, corrected delegate-hole/
wrapper counts (blast SHOULD-FIX 3: 10 not 15, 11 not 12), the
allegiance register-row re-evaluation conclusion (blast SHOULD-FIX 6 --
no row needed, MF-1's fix retires the deviation entirely), and the
reconciled fix-round test totals (13,201/4/0 -> 13,215/4/0, +14,
arithmetic exact per file).
Seam map (docs/research/2026-08-11-fa-acdream-seams.md), per the FA1
fix-round's established in-place-correction convention:
- SS1.3 and SS9's dispatcher-replaces-not-chains correction is now dated
and cites the actual GameEventDispatcher.Dispatch behavior, matching the
code comment already landed in GameEventWiring.cs.
- SS2.3 gets the 0x01C9/0x01CA disposition it was missing (correctly
left unregistered -- dead COMDAT-fold no-ops per FA1) so FA3 does not
have to re-derive it or "fix" the gap.
- The SS8 seam-map table's Allegiance-owner row and the executive-summary
ownership bullet both get the "survives reconnect" claim struck with a
dated correction to "session-scoped, clears at every generation reset".
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Remaining SHOULD-FIX findings from the FA2 mechanism/blast reviews:
Mechanism SF-3/SF-4 -- RuntimeFellowshipState.ApplyUpdateFellow now ports
Fellowship::RecalculateEvenXPSplitting @0x005B92E0 (called from retail's
AddFellow/UpdateFellow/RemoveFellow on every upsert/removal, but never
from a full update -- that carries the server's own authoritative flag
verbatim, lane B 6.2) and Fellowship::AddFellow @0x005B9480's
locked/departed admission gate (a brand-new guid is refused while
_locked unless it appears in the 0x02BE field-8 _fellows_departed table
within 900s, @0x005B94A5). ApplyFullUpdate now stores update.Departed
instead of discarding it. A TimeProvider dependency (defaulting to
TimeProvider.System, matching the RuntimeCharacterOptionsState precedent)
makes the 900s grace window testable.
Mechanism SF-5 -- RuntimeAllegianceState's TryGetMember/TryGetPatron/
GetVassals now reuse ClientCommandResponses.AllegianceProfileLookups
(promoted private -> internal, AcDream.Runtime added to Core.Net's
InternalsVisibleTo) instead of re-implementing the retail walk a second
time.
Mechanism SF-6 -- RuntimeStateCheckpoint's Fellowship/Allegiance
parameters are no longer trailing-optional. `default(RuntimeFellowshipSnapshot)`/
`default(RuntimeAllegianceSnapshot)` zero-init Name/AllegianceName to
null, and C# does not allow a non-constant `new(...)` as an optional
parameter's default value (CS1736) even when the struct declares an
explicit parameterless constructor -- so the only way to guarantee a
non-null default was to make the parameters required. Both snapshot types
still gained an explicit parameterless constructor for callers that want
an empty-but-safe `new()`.
Blast SF-4 -- LiveSessionEventRouterTests gains
FellowshipQuit_RoutesSelfGuidToClearAndOtherGuidToRemove, wiring real
RuntimeFellowshipState/RuntimeAllegianceState owners through the one
production registration site and dispatching a real 0x00A3 envelope for
both a self-quit and an other-quit -- the one non-trivial lambda in the
slice (the self-guid source that decides "remove one member" vs "clear
the whole snapshot") was previously untested; every other router test
defaults Fellowship/Allegiance to null.
Blast SF-5 -- RuntimeFellowshipState.ResetSession dropped its disposed
guard to match the precedent its own doc comment names
(RuntimeInventoryState.ResetExternalContainer,
RuntimeCommunicationState.ResetNegotiatedChannels -- both bare delegations
with no disposal guard); the reset transaction is retryable and disposal
is terminal, so a throwing guard could never converge on retry.
RuntimeAllegianceState.ResetSession (new this fix round) matches the same
shape from the start.
Blast SF-7 -- IRuntimeAllegianceView.GetVassals' per-call List<> allocation
is now documented as an intentional exception to the file's "Snapshot +
TryGet*, no allocation" view convention (C# cannot yield-return from
inside a lock).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two MUST-FIX findings from the FA2 mechanism/blast reviews
(docs/research/2026-08-12-fa2-review-mechanism.md,
docs/research/2026-08-12-fa2-review-blast.md):
MF-1 (mechanism) -- RuntimeAllegianceState survived a generation reset,
contradicting retail (ClientAllegianceSystem::OnEndCharacterSession
@0x00569FA0 tail-calls AllegianceProfile::Clear at the same boundary
Fellowship already clears at), contradicting the precedent it cited
(RuntimeCharacterOptionsState.ResetSession clears-and-relatches, it does
not persist), and pinned by a test asserting the wrong behavior. Fixed:
RuntimeAllegianceState.ResetSession() clears the profile and drops
HasServerSeed; a new RuntimeGenerationResetStage.Allegiance stage runs it
on every generation reset, mirroring RuntimeFellowshipState exactly.
RuntimeGenerationResetTests' FellowshipClearsAtResetButAllegianceSurvivesReconnect
inverted to FellowshipAndAllegianceBothClearAtGenerationReset.
MF-2 (mechanism) / blast MF-2 -- 0x027C AllegianceInfoResponse fed the
Runtime allegiance owner (self-gated). Retail's own handler for 0x027C
(CM_Allegiance::DispatchUI_AllegianceInfoResponseEvent @0x006a7470) unpacks
into a stack-local profile destroyed on return; the consumer
(Handle_Allegiance__AllegianceInfoResponseEvent @0x0056a1d0) only prints
AddTextToScroll lines. Retail's panel is fed exclusively by 0x0020
AllegianceUpdate. The removed seeding also fabricated
RuntimeAllegianceSnapshot.Rank (0x027C carries no rank field) on any
client whose first allegiance message was a self @allegiance info query.
Fixed: dropped ApplyInfoResponseSelf, the onAllegianceInfoResponseSelf
delegate hole, and the self-gate; 0x027C is text-only again, matching
retail and the pre-FA2 shape.
Also covers blast SHOULD-FIX 1 in the same edit to LiveSessionEventRouter.cs:
the fellowship/allegiance delegate holes are now passed conditionally on
the owner being supplied, so GameEventDispatcher.GetUnhandledCount reads
correctly for callers without an owner (bare-ChatLog tests, a future
partial host) instead of silently reading 0 for 9 event types whose parse
result was discarded.
RuntimeAllegianceState.cs and the two owners' Apply* mutators also move
their ObjectDisposedException.ThrowIf checks inside the lock they already
take (mechanism SHOULD-FIX 2) -- the prior check-then-lock shape let an
inbound event on the decode thread race Dispose on the host thread and
repopulate state after _disposed = true, permanently falsifying
CaptureOwnership().IsConverged at teardown.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Both MUST-FIX findings are on RuntimeAllegianceState and they compound.
MF-1: the owner participates in no reset stage. Retail does the
opposite at the same boundary -- ClientAllegianceSystem::
OnEndCharacterSession @0x00569FA0 tail-calls AllegianceProfile::Clear,
while its sibling ClientFellowshipSystem::OnEndCharacterSession
@0x005690A0 deletes m_pFellowship (so FA2's fellowship half IS
faithful). The precedent the code and lane D §1.3 both cite,
RuntimeCharacterOptionsState.HasServerSeed, CLEARS at ResetSession and
its own doc names this hazard. The graphical host passes no character
selector, so TrySelectFirstAvailable re-resolves the character from a
fresh server list every generation -- a cross-character reset is not
precluded, and nothing in the owner keys on identity. Already pinned
by a passing test.
MF-2: ApplyInfoResponseSelf seeds from 0x027C. Retail's dispatcher
@0x006A7470 unpacks into a stack-local profile and its handler
@0x0056A1D0 only prints; 0x0020's handler @0x0056A120 is the single
inbound writer of the cached profile. Carries a stale-Rank
second-order defect (0x027C has no rank field).
Verified clean and re-derived from the decomp: all six fellowship
lifecycle rules, the exact leader hand-off condition (case 8 vs case
0xC at @0x0049034B/@0x004903EF), the dispatcher-folding correction and
byte-identical @allegiance info output, D4's 0x00A6 present but never
fired, both bindings sites symmetric, IRuntimeEventObserver untouched,
TS-81 honest, and the 8-edit J-owner template incl. teardown masks.
43/43 targeted Runtime tests pass on the committed Release binaries.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Blast-radius review of FA2 (1c401048, 369729f0, cced83b4, 12053e61)
along the axes the implementer did not traverse.
MF-1: GameRuntime.cs:716-725 -- CompletedTeardownStages case 9 claims
FellowshipDisposed one stage early (10 flags where the pre-FA2 case had
exactly 9, ending at CommunicationDisposed). Only observable on the
teardown failure path, which is precisely when the ledger must be
honest. No test pins intermediate stages, so nothing caught it.
MF-2: seeding RuntimeAllegianceState from 0x027C AllegianceInfoResponse
is a retail divergence with no register row. Retail's
CM_Allegiance::DispatchUI_AllegianceInfoResponseEvent @0x006a7470
unpacks into a STACK-LOCAL CAllegianceProfile and destroys it on return;
Handle_Allegiance__AllegianceInfoResponseEvent @0x0056a1d0 uses it only
as a read source for AddTextToScroll. 0x027C is text-only in retail; the
panel is fed exclusively by 0x0020. Concrete risk: 0x027C carries no
rank, so an @allegiance info before the first 0x0020 leaves the owner at
HasProfile=true with a fabricated Rank=0 for FA3's panel to render.
SHOULD-FIX: the seam doc still carries the false dispatcher claim FA2
disproved (only the plan ledger and a code comment were corrected);
no disposition recorded for the two deliberately-skipped dead events;
three count claims wrong (15 delegate holes -> 10; 12 Send wrappers ->
11; 11 S->C events -> 10); no test covers the router->owner plumb
including the one non-trivial lambda; ResetSession's disposal guard
diverges from the precedent it cites; allegiance reconnect-survival has
no register row; GetVassals allocates against the stated view contract.
Verified clean and enumerated exhaustively: every WireAll site (one
production, shared by both hosts), both bindings sites, every
IGameRuntimeCommands/IGameRuntimeView implementer (no bot-reachable
stub), zero auto-fire on all 11 new Send wrappers incl. 0x00A6/0x001F,
reset- and teardown-stage renumbering at every enumeration point, the
central accepting gate, host-adapter self-guid and owner-borrow
equivalence, the K-slice bot policies + trace recorder (21/21), the
@allegiance info live path (79/79), and the suite accounting -- measured
13,201/4/0 (13,205 total) with the +43 reconciled per test file.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
TS-81 filed: 0x027A AllegianceLoginNotification's retail two-line chat
text is not emitted -- the candidate strings resolve through Binary-
Ninja-mislabeled symbols at gmAllegianceUI::RecvNotice_AllegianceLogin
(0x00492220) that need a DAT string-table lookup, not a guess.
TS-80 partially narrowed: the fellowship-create shareXp wire mechanism
now exists end-to-end (IRuntimeFellowshipCommands.Create takes and
sends it), but no caller reads the option bit yet -- that's FA4's
create-dialog scope.
Updates the campaign plan's FA2 ledger row: code-complete, full test
totals (13,158/4/0 -> 13,201/4/0, +43 exact), the seam-doc dispatcher
correction, and the entity-table-borrow recommendation that wasn't
needed (the wire's own FellowMember record already carries full vitals
inline).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Both LiveSocialSessionBindings construction sites updated together
(LiveSessionRuntimeFactory.cs, HeadlessSessionHost.cs) so the single
GameEventWiring.WireAll registration site serves both hosts identically
(the K-slice unification). CurrentGameRuntimeCommandAdapter implements
IRuntimeFellowshipCommands/IRuntimeAllegianceCommands over the App
command bus (LiveSessionCommandRouter gains 12 new *RuntimeCmd records
+ registrations + LiveSessionCommandBindings send delegates), mirroring
DirectGameRuntimeCommandAdapter's direct-session shape including the
identical Quit leader-hand-off rule read from RuntimeFellowshipState.
CurrentGameRuntimeAdapter (the graphical IGameRuntimeView/
IGameRuntimeCommands composite) exposes the two new views/command
groups. Headless's DirectGameRuntimeCommandAdapter needed no changes --
it already implements both new interfaces from the Runtime-layer
commit.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two new sibling Runtime owners under GameRuntime per the Slice-J
pattern (D2): RuntimeFellowshipState is session-scoped (a new
RuntimeGenerationResetStage.Fellowship clears it at every generation
reset, matching the ExternalContainer precedent); RuntimeAllegianceState
survives reconnect behind a HasServerSeed-style one-way latch and
participates in NO reset stage (its data persists like a real
disconnect does not sever allegiance membership).
Fellowship: full-update REPLACE, incremental-fellow UPSERT, self-vs-
other quit/dismiss removal (self clears the whole snapshot), disband
clear, and retail's leader hand-off rule for the Quit button
(RequiresLeaderHandoffBeforeQuit -- the current leader quitting WITHOUT
disbanding must send 0x0290 AssignNewLeader before 0x00A3, lane B
§2.5/§3.6).
Allegiance: seeded by AllegianceUpdate (0x0020, always self) and,
self-gated on TargetGuid == playerGuid(), by AllegianceInfoResponse
(0x027C); wraps the FA1-assembled flat AllegianceMemberRecord list
directly (AllegianceTree was deleted at FA1 -- nothing left to wrap).
Both apply the full 8-edit J-owner template: construction + fault
points + Owner/View properties + CaptureOwnership + a new
GameRuntimeTeardownStage pair (FellowshipDisposed/AllegianceDisposed,
stage count 11->13) + RuntimeGameplayOwnershipSnapshot inclusion +
RuntimeStateCheckpoint/trace fields. IRuntimeFellowshipCommands/
IRuntimeAllegianceCommands added to IGameRuntimeCommands and
implemented on DirectGameRuntimeCommandAdapter. No IRuntimeEventObserver
member added (D2) -- consumers poll Snapshot.Revision.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds the missing WorldSession.Send* link for every FA1 fellowship/
allegiance builder (SendFellowshipCreate/Quit/Dismiss/Recruit/
UpdateRequest/AssignNewLeader/ChangeOpenness, SendAllegianceSwear/
Break/Kick/UpdateRequest) and 15 new GameEventWiring.WireAll delegate
holes covering the 11 S->C fellowship/allegiance events. Delegate holes
(not state-object params) because Core.Net cannot reference
AcDream.Runtime, matching the onCharacterOptions/onConfirmationRequest
precedent.
Fixes a real bug found during implementation: GameEventDispatcher.
Dispatch invokes only the single most-recently-registered handler per
GameEventType (RegisterOwned REPLACES, it does not chain-invoke) --
contradicts the seam doc's "the dispatcher supports multiple owned
handlers per type" claim. A literal second registrar.Register call for
AllegianceInfoResponse would have silently killed the already-live
`@allegiance info` chat-text output the moment a caller supplied the
new self-gated Runtime callback. Both behaviors are folded into the
ONE existing registration instead.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The narrow re-review (96df892d) closed FA1 with no reopen and one
carry-forward: two further forward-looking seam-map rows (:128 owner
diagram, :214 state-parameter pattern) still cited the deleted
AllegianceTree as FA2 design guidance. Both now carry dated strike/
addendum notes pointing FA2 at the parsed profile records instead.
FA2 unblocked per the re-review's precondition.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Narrow re-review of the fix round (ed308087 code+tests, 511ba6e5 docs)
against the mechanism findings doc. Verdict CLOSED, no REOPEN.
All 2 MUST-FIX and 5 SHOULD-FIX verified in the actual diffs, each
re-derived rather than taken on the commit message's word:
- MF-1: zero-id rejection on BOTH the monarch and child paths, which is
also what makes treeParent == 0 provably fatal (knownIds can never
contain 0); three boundary tests.
- MF-2: the 0x001F builder re-verified against primary source --
CM_Allegiance::Event_UpdateRequest @0x006A7260 allocates 0x10, stores
0x1f at 006a72ba, writes the arg as a full u32 at 006a72cb. Both golden
vectors correct; all five new anchors resolve; the ACE claim
(GameActionAllegianceUpdateRequest.cs:12 reads and ignores the value)
is accurate.
- SF-1: monarch clear placed at retail's own position/guard; the fixture
relocation onto a vassal is not just correct but necessary, since the
clear would otherwise mask the legacy-compat fallback.
- SF-2/SF-3/SF-5 all closed; SF-5 resolved better than asked, renumbering
to the real AllegianceVersion enum values (verified against
acclient.h:2979-2994) and naming gate 5 as real-but-gating-nothing.
Spot-verified all six blast dispositions: AP-90 re-pointed without being
wrongly retired; four seam-map corrections applied as dated strikes (its
open-question-8 answer independently re-verified against PackString16L
and ACE's ReadString16L pad skip); D9 + slice row struck and annotated;
ledger arithmetic now closes (13,153 total sums correctly, -4 skips =
13,149).
Suite claim corroborated: the fix diff adds exactly +9 [Fact]/[Theory]
and removes 0, and the post-fix Release binaries (stamped after
ed308087, so --no-build is legitimate here) measure AcDream.Core.Net.Tests
at 886/0/0 -- exactly the blast doc's 877 pre-fix anchor plus 9, with all
9 new tests in that project.
One carry-forward, NOT a reopen: blast MF-2's enumeration stopped at four
rows; lane D still names the deleted AllegianceTree at :128 and :214,
both forward-looking FA2 design guidance of the same danger class as the
:791 row that was corrected. Two more dated addenda close it; FA2 should
not start before that.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Applies the documentation-only MUST-FIX items from the blast review
(docs/research/2026-08-12-fa1-review-blast.md), plus mechanism SF-4:
Blast MF-1 / mechanism SF-4: register row AP-90
(retail-divergence-register.md) cited the deleted AllegianceTree class as
its evidence. Re-pointed to ClientCommandResponses.AllegianceProfileLookups
and the fellowship parsers FA1 added -- the deviation itself (radar
relationship state undelivered at runtime) is unchanged and NOT retired,
since FA2 hasn't wired a live owner yet.
Blast MF-2: corrected four falsified statements in the lane D research doc
(fa-acdream-seams.md), each marked with a dated, clearly-struck FA1
fix-round addendum rather than silently rewritten (it is a committed
research record):
- :791 "wrapping existing AllegianceTree" -- class deleted; re-pointed to
AllegianceProfileLookups.
- :666/:672 `commands.Fellowship.SetOpen -> BuildFellowshipUpdate` -- that
builder no longer exists; its renamed successor is panel visibility,
not openness, and using it here would re-introduce the exact semantic
bug FA1 fixed. Re-pointed to BuildFellowshipChangeOpenness (0x0291).
- :429/:668 `BuildFellowshipCreate(seq, name, openness, shareXp)` -- the
builder is now 3-arg; there is no wire openness field.
- :854 open question 8 (trailing-pad rule) -- ANSWERED by FA1 (VC-3),
closed with the answer instead of left open for re-derivation.
Blast MF-3: plan decision D9 and the FA1 slice-map row both asserted "the 8
missing fellowship WeenieError strings are added in FA1" -- FA1 shipped the
opposite, verified finding (no retail display text exists for any of the
8 ids). Both struck and annotated with the actual outcome.
Blast MF-4: reconciled the ledger's internally-inconsistent test-total row.
Direct measurement at the pre-fix-round tip (bc693728, stashed/restored
during this session to isolate it) confirms 13,149 passed / 4 skipped / 0
failed (13,153 total) -- the ledger's own prior number was actually
correct; the "baseline 13,103" and "net +50" framing next to it did not
reconcile with each other or with the diff-verified delta (+58 added / -9
deleted = net +49, one test of drift attributed to a different baseline
commit, not a further miscount). Also records this session's own +9 tests
and the blast SF-1 live-surface note (FA1 changed observable @allegiance
info output, not a purely-unwired slice).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Applies both MUST-FIX items and the code-facing SHOULD-FIX items from the
dual-lens FA1 review (docs/research/2026-08-12-fa1-review-mechanism.md,
docs/research/2026-08-12-fa1-review-blast.md):
Mechanism MF-1 / blast SF-2: AllegianceHierarchy::Add @0x005B6E90 wraps its
entire body in `if (_id != 0)` -- a record whose own id is zero discards
the WHOLE message, for both the monarch and a child record, and this is
also what makes treeParent == 0 unconditionally fatal for a non-monarch
record. ReadAllegianceProfileBody now rejects CharacterId == 0 on both
paths; four new boundary tests in AllegianceProfileVersionGateTests.cs
(zero-id monarch, zero-id child, zero treeParent, plus the existing
orphan/self-parent/duplicate trio).
Mechanism MF-2: added the missing 0x001F AllegianceUpdateRequest builder --
the structural twin of the fellowship 0x00A6 this slice already repaired --
with golden-vector tests for both on/off.
Mechanism SF-1 / blast SF-3: UnPack's last act before returning success
forces the monarch's MayPassupExperience to false regardless of the wire
bit or the HasPackedLevel-absent legacy-compat fallback. Ported at the end
of the record loop; the pre-existing HasPackedLevel-absent test moved off
the monarch record (which the new clear makes indistinguishable from "the
fallback never fired") onto a vassal record, and a new test proves the
monarch clear fires even when the wire bit explicitly asks for true.
Mechanism SF-2: removed ParseFellowshipDisband's invented body-length
validation -- retail's DispatchUI_Disband reads only the opcode and never
inspects a trailing body. The parser now always succeeds; the matching
test flips from asserting rejection to asserting acceptance.
Mechanism SF-3: added the D5 `<<1` shareLoot-shape test at the 0x02C0
FellowshipUpdateFellow site -- previously only pinned at 0x02BE, so a
future split of the shared ReadFellow helper could silently reintroduce a
bool read on this leg undetected.
Mechanism SF-5: renumbered the version-gate comments in
ReadAllegianceProfileBody to the true AllegianceVersion enum values
(1-11, matching acclient.h's SpokespersonAdded..ApprovedVassal) instead of
wire-appearance order, which only reached 10 and silently dropped gate 5
(BannedCharactersAdded, which is real but gates nothing in UnPack -- now
called out explicitly). Fixed the stale "lane B §12" citation in
SocialActions.cs to the actual master-table row.
Blast SF-1: pinned the two retail-faithful but user-visible behavior
changes FA1 made to the ALREADY-LIVE `@allegiance info` command --
reversed vassal print order (3-vassal test through
FormatAllegianceInfoLines) and malformed-tree silent-drop (test at the
GameEventWiring registration layer, which is `if (info is null) return;`).
Blast SF-4: fixed a doc comment citing a nonexistent `ConfirmationResponseTests`
class; the actual class is `ConfirmationTripleTests`.
Blast SF-5: cross-referenced the confirmation-triple discriminator's split
representation (ConfirmationType on the response leg only; bare uint on
the two inbound legs production actually reads) at both sites, so FA4
inherits a stated decision rather than an unexplained inconsistency.
Full Release suite: 13,158 passed / 4 skipped / 0 failed (13,162 total),
up from the pre-fix-round 13,149/4/0 (+9 tests this round).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Mechanism-faithfulness lens on 7be86f47/6bedbc47/5f9aa16f/4281750b.
Every wire layout re-derived independently from the retail decomp rather
than taken on the lane docs' word: Fellow::UnPack, Fellowship::UnPack,
PackableHashTable::UnPack's count/buckets split, DispatchUI_UpdateFellow's
guid-first read, Event_Create's single trailing u32 shareXP,
AllegianceHierarchy::UnPack's eleven gates + their non-monotonic wire
order, AllegianceHierarchy::Add, AllegianceProfile::UnPack, the 0x20
dispatch case. All six golden byte vectors re-computed field by field --
no encoding, padding, or endianness slip found.
Both premise-contradiction calls VERIFIED CORRECT from primary source:
the 8 fellowship WeenieError ids genuinely have no case label, no else-if
comparison, no decimal form and no default fallthrough in
HandleFailureEvent (D9's premise was wrong, the refusal to invent English
was right); and 0x0275 is client-authored, so the typed ConfirmationType
enum -- not a receive parser -- was the real gap, and D6's FA4/FA5 flows
are buildable on what landed.
MUST-FIX: (1) AllegianceHierarchy::Add's fourth rejection rule (_id == 0,
which also makes treeParent == 0 unconditionally fatal) is unmodeled;
(2) the 0x001F AllegianceUpdateRequest builder -- the allegiance twin of
the 0x00A6 this slice repaired, and lane C's #3 minimum-viable message --
is missing entirely.
SHOULD-FIX: monarch MayPassupExperience is not force-cleared;
ParseFellowshipDisband validates a body length retail never inspects;
D5's <<1 shape is unpinned at the 0x02C0 site; AP-90's register row still
cites the deleted AllegianceTree; the gate comment numbering stops at ten.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Blast-radius lens over Campaign FA slice FA1 (7be86f47, 6bedbc47,
5f9aa16f, 4281750b, ee1124ca). The wire work is right -- both repaired
builders re-derived against ACE's own handlers, all eleven allegiance
version gates against AllegianceHierarchy::UnPack @0x005B7520, and both
tree-assembly rules against Add @0x005B6E90. No handler-lane collision,
no double registration, no cross-host source touched.
The bookkeeping is not. MUST-FIX: register row AP-90 still cites the
deleted AllegianceTree; lane D's seam map -- FA2's own contract -- is
falsified in four places including a row that would make FA6 re-introduce
the exact openness/panel-visibility bug FA1 fixed; plan decision D9 still
says the 8 WeenieError strings were added when FA1 shipped the opposite
finding; and the ledger's test totals state three mutually exclusive
numbers (+46 implied, +50 stated, +49 measured from the diffs).
SHOULD-FIX: the "UNWIRED" framing is untrue of ParseAllegianceInfoResponse
(live behind @allegiance info -- vassal print order now reverses and a
malformed tree now silences the command outright; both retail-faithful,
neither pinned by an order-sensitive test); the id != 0 discard rule is
missing; retail's monarch MayPassupExperience zeroing is missing; one new
doc comment cites a nonexistent test class; and the confirmation triple
now carries its discriminator two ways.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Records the FA1 commit SHAs and automated-gate test totals in Campaign
FA's ledger (§9). Owes its dual-lens review per the campaign's §7
protocol before FA2 (the RuntimeFellowshipState/RuntimeAllegianceState
owners) begins.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Campaign FA slice FA1, lane C §0/§7.3 DELETE verdict. Verified zero
production references (a repo-wide grep for AllegianceTree/
AllegianceNode/AllegianceMath outside the class's own file and its own
test file returns nothing but docs and an unrelated test-name string
coincidence in ChatChannelInfoTests.cs).
Two defects made this safer to delete than fix:
- AllegianceMath.ComputePassup transcribed retail's passup formula wrong
by roughly 1000x: it computed (50 + 22.5*loyalty) / 291 instead of
50 + 22.5*(loyalty/291) AS A PERCENTAGE (missing the trailing / 100),
and its own unit test locked the wrong value in as correct.
- AllegianceTree's UpsertNode(guid, name, patronGuid, rank) modeled a
patron edge the wire does not carry -- the wire names each record's
TREE PARENT (§4.4), which for ACE's own writer is not always the real
patron (ACE hangs a non-monarch patron directly off the monarch). The
parsed record list plus its treeParent tags already IS the tree
(see ClientCommandResponses.AllegianceProfileLookups, landed in the
companion feat(net) commit this session) -- no separate tree class is
needed. The client also never needs the passup number at all:
_cp_tithed arrives pre-computed from the server.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The FA1 contract asked to "add the 8 missing fellowship entries lane B
lists" (0x0417-0x041C, 0x04DB, 0x04DC). Re-ran this table's own binary-
sweep methodology specifically for these 8 ids rather than inventing
text for them: a full-text grep of the 1.4M-line
acclient_2013_pseudo_c.txt found ZERO comparisons/case-labels against any
of the 8 anywhere in the retail client, and a manual walk of
HandleFailureEvent's own case-label sequence confirmed the switch goes
straight from case 0x416/0x41d (skipping 0x417-0x41c) and from case
0x4da/0x4dd (skipping 0x4db/0x4dc).
Conclusion: retail's Sept-2013 client has no display text for any of
these 8 ids -- they are intentionally absent from this table, not
overlooked. This contradicts the FA1 contract's premise but not lane B's
own text, which only claimed the ids were "missing" from the table (true)
and that two of them (0x0417, 0x04DB) are on ACE's live send paths (also
true) -- it never claimed retail has text for them. Two of the ids are
therefore live-but-silent gaps against a real ACE server, and acdream's
current no-display behavior for them is ALREADY retail-faithful. Adding
invented English would be exactly the class of mistake SHOULD-FIX 4
(the no-default-case rule this table's Resolve() already implements)
exists to prevent.
Documents the finding at both table gaps and adds a conformance test
(Resolve_FellowshipIdsAbsentFromHandleFailureEvent_ReturnsNoText) proving
all 8 resolve to null text, matching the existing
Format_0x051D_ReturnsNull_NoRetailCaseExists precedent.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Campaign FA slice FA1: pure parse functions + typed records only,
UNWIRED (FA2 registers them against the new RuntimeFellowshipState/
RuntimeAllegianceState owners -- see docs/research/2026-08-11-fa-acdream-seams.md
§2).
Fellowship family (GameEvents.cs), field orders from lane B §3.8-§3.13,
guid-first on 0x02C0 per the resolved Chorizite disagreement:
FellowshipFullUpdate (0x02BE), FellowshipUpdateFellow (0x02C0),
FellowshipQuitNotice/FellowshipDismissNotice (S->C 0x00A3/0x00A4),
FellowshipDisband (0x02BF, empty body), and the dead
FellowshipFellowUpdateDone/FellowshipFellowStatsDone (0x01C9/0x01CA,
parse-and-ignore, must never fail per lane B §2.7). ShareLoot is modeled
as a raw uint (D5) -- ACE encodes it two incompatible ways (0x10 in full
updates, <<1 incremental), so `!= 0` is the only safe read, never `== 1`.
Confirmation triple (D6): grepping the tree showed 0x0274/0x0276 already
had typed parsers in Core.Net; 0x0275 (client-authored) already had a
byte-correct builder but no typed representation. Added the
ConfirmationType enum (1 SwearAllegiance, 4 Fellowship, matching retail's
Handle_Character__ConfirmationRequest switch and ACE's enum verbatim) and
ParseConfirmationResponse, completing Core.Net's typed coverage of all
three legs and round-tripping against the existing
ClientCommandRequests.BuildConfirmationResponse byte-for-byte.
Allegiance small events (GameEvents.cs): AllegianceLoginNotification
(0x027A), AllegianceUpdateDone (0x01C8), AllegianceUpdateAborted (0x0003,
declared but never sent by ACE).
The heavyweight AllegianceUpdate (0x0020) extends
ClientCommandResponses.ParseAllegianceInfoResponse (0x027C) rather than a
second parser, per lane C §7.2's explicit reuse verdict -- both messages
now share ReadAllegianceProfileBody, which the discriminating leading u32
(targetGuid vs rank) is read around. That shared reader implements:
- The ELEVEN AllegianceHierarchy::UnPack version gates (lane C §4.2) --
officers/spokesperson-skip, officer titles, the four broadcast
counters, motd/motdSetBy, chatRoomId, bind point, allegianceName,
isLocked, approvedVassal, each behind its own oldVersion threshold.
AllegianceProfileVersionGateTests.cs pins all eleven with a
boundary-crossing pair per gate (N-1 OFF vs N ON), including the
negative proof that version 5 (BannedCharactersAdded) gates nothing
in UnPack.
- The §4.4 tree-assembly rules: a record whose treeParent is not already
in the tree (orphan), equals its own id (self-parent), or duplicates an
id already seen makes AllegianceHierarchy::Add fail, which the whole
parse now mirrors by returning null for the ENTIRE message -- not a
partial tree. Sibling order REVERSES on assembly (each new record is
prepended to its parent's vassal list), so FindVassals now walks
records in reverse wire order; both rules have dedicated tests.
- AllegianceMemberRecord gained the panel-needed columns lane C §7.2
names (rank, level, loyalty, leadership, cpCached, cpTithed, gender,
heritage, MayPassupExperience) with defaulted trailing parameters so
existing 4-arg positional construction sites keep compiling. Officers/
officer titles/bind point are read (so every later field lands at the
right offset) but deliberately left unsurfaced -- ACE always zeroes/
empties them anyway (lane C §5.1), and bind point is a 32-byte Position
the retail chat renderer never uses either; a future panel slice can
extend the record without re-deriving the parse.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Campaign FA slice FA1 (lane B field-order sections §3.1-§3.7, lane C §3.2,
§1.4). Two latent acdream builder defects repaired, both cited in lane B
§5.2:
- BuildFellowshipCreate (0x00A2) invented a nonexistent "openness" byte
and silently sent it as the low byte of shareXP -- ACE would read an
INVERTED shareXP value. Corrected to retail's real shape: [str16L
name][u32 shareXP]. shareXP is the FellowshipShareXP character option,
not a dialog checkbox.
- BuildFellowshipUpdate(open:) mislabeled 0x00A6 as fellowship openness;
it is FellowshipUpdateRequest -- panel VISIBILITY. Renamed to
BuildFellowshipUpdateRequest(panelOpen:); the wire bytes were already
correct, only the name/doc were wrong. ACE gates the whole 0x02C0
member-vitals stream on this message (lane B §4.5) -- a prerequisite
for live vitals once FA4 wires the panel.
Two builders added that acdream never had at all:
- BuildFellowshipAssignNewLeader (0x0290) -- retail's leader-Quit path
sends this before 0x00A3 disband=0 (lane B §2.5).
- BuildFellowshipChangeOpenness (0x0291) -- the REAL openness toggle.
- AllegianceRequests.BuildKick -- wire-identical to BuildBreak (both are
Event_BreakAllegiance 0x001E); named separately so FA2's panel command
surface can distinguish "break from patron" from "kick a vassal" (lane
C §1.4). AllegianceInfoRequest (0x027B) was already live via
ClientCommandRequests.BuildAllegianceInfoRequest -- not duplicated.
Wrong-shape tests at SocialActionsTests.cs:53-105 re-pinned with
hand-computed golden byte vectors deriving each field from the cited
lane-B sections (not generated by calling the builder under test, per the
OP1 convention this file already follows for BuildSetCharacterOptions).
AllegianceRequestsTests.cs gained golden vectors for the existing
Swear/Break builders plus the new Kick alias.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Seven slices over the four committed research lanes + the U2 slot-table
closure: Core.Net truth (repair/complete/delete the H.2 scaffolding),
two sibling J-owners with different lifetimes, the OP3-recipe mount of
the ONE four-tab social panel (id 12: Friends/Allegiance/Fellowship/
Squelch), the two live pages, bot-vs-ACE gates (second ACE account is
the user prerequisite at FA6), and closeout. Nine design decisions
stated (D1-D9); Campaign OP's gate lessons imported as binding rules.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Four Opus research lanes for the Fellowship & Allegiance campaign:
panel structure, fellowship wire (two BN zero-folds broken by byte
decode: IsFull >= 9, the x87 XP-share table capping at 2.8x), allegiance
wire (27+5 messages binary-verified; tree assembly discard/reversal
rules; ACE zeroed-field caveats), and the acdream seams audit (H.2
scaffolding inventory, J-owner recommendation, AD-78 dimmed-row
inventory, bot-gate requirements).
Coordinator U2 closure (FaPanelSlotProbeTests, live DATs): Fellowship
and Allegiance are two of FOUR pages of ONE tabbed social panel — slot
0x1000018F, panel id 12, Type-8 host — alongside gmFriendsUI and
gmSquelchUI; lane A's separate-siblings mounting call is corrected in
its addendum, and the full 16-slot dump closes every unidentified
RetailPanelCatalog entry (Abuse/Book/LinkStatus/MiniGame/UA/Vitae/
Map+House/Journal).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Root cause (found via reference-identity-verified live-DAT probing, not
a guess): the four main-chat-window indicator buttons (0x10000522-
0x10000525) resolve their own correct ActiveState="Normal" at
construction, then get blanked to "" moments later in the SAME
LayoutImporter.Build call. The indicator column's backing panel
(0x10000600) authors PassToChildren=true on its own empty DirectState
(confirmed live: States[0xFFFFFFFF].PassToChildren == true); when
LayoutImporter.BuildWidget's post-attach state reapply runs for that
panel, UiDatElement.TrySetRetailState cascades its DirectStateId to
every IUiDatStateful child, including the already-correctly-resolved
buttons. UiButton.TrySetRetailState's DirectStateId branch used to
accept that cascade because every button structurally carries a
DirectStateId entry in its States dict as a property bag (ToggleBehavior/
RolloverEnabled/etc), independent of whether it authors any blank
sprite, so TryFindState(DirectStateId) found that entry and blanked
ActiveState even with no "" media. A hover "fixed" it only because
UiButtonStateMachine.RequestedState resolves to the same canonical
Normal id regardless of PointerOver when RolloverEnabled is false.
Retail's own decompiled UIElement::SetState @0x00464e70 does the exact
same unconditional-commit-plus-cascade; retail avoids this specific bug
purely through construction timing (UIElement::Initialize's SetState
call precedes child-tree construction, so a cascade fired during import
always iterates zero children). Our port's LayoutImporter.BuildWidget
deliberately reapplies in the opposite order to give retained
PassToChildren tabs their authored child media, so this literal
state-machine port needed a compensating guard.
Fix: UiButton.TrySetRetailState's DirectStateId branch now requires
REAL "" media (HasStateMedia("")) before accepting the transition.
Scoped to UiButton only; UiDatElement's parallel branch and the cascade
mechanism are unchanged, so CharacterStatController's own
PassToChildren-driven chrome children are unaffected. Register row
AP-206 records the divergence from retail's literal unconditional-
commit semantics. Regressed by two fast unit tests in UiButtonTests.cs
(DirectStateCascade_WithoutRealMedia_DoesNotBlankAnAlreadyResolvedState,
DirectStateTransition_WithRealMedia_StillSucceeds) plus a live-mount
probe confirming all four buttons resolve ActiveState="Normal"
immediately after import against the real installed DAT.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Root cause: a live-DAT probe found retail authors NO backing element
behind the Character/Chat/Config tabs' Apply/Reset/Defaults footer —
each page root has exactly five children (the row ListBox, its
scrollbar, and the three buttons) with zero direct-state media on the
root itself. Scrolled row content therefore bled through visibly
between/behind the three buttons; the bleed-through is a rendering gap
in our own composition, not a missing import.
Fix: new minimal widget UiSolidSpriteFill tiles
RetailChromeSprites.CenterFill (the SAME panel-background sprite the
Options window's own chrome already draws behind everything, not an
invented color) across the footer strip's rect, derived from the three
buttons' own resolved Top/Height and z-ordered strictly behind every
other child so it can never intercept input or occlude the buttons.
Register row AP-205 records the synthesis. Regressed by
OptionsPanelControllerTests.
Bind_SynthesizesOneOpaqueFooterBacking_PerPageWithApplyResetDefaults,
which pins exactly one backing field per page, sized from the live
button rects, z-ordered behind every sibling.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Root cause: PlayerOptionPage::AddSliderOption never sets a row's own
name-label text — the "Inactive Opacity"/"Active Opacity" caption comes
from a SEPARATE DAT-resident runtime catalog (DID 0x78000000, resolved
via the same two-level DBCache::GetDIDFromEnumStatic master-map/submap
lookup ChatOptionsDatDefaults already uses for enum 0x16/category 2,
here for enum 0x15/category 2) that nothing in the codebase ever
queried, so both slider rows rendered with no caption at all.
Fix: new ChatOptionsDatCaptions.TryRead resolves the DID-0x78000000
catalog's per-property name/tooltip entries (matched by the same
owning-property enum ChatOptionsDatDefaults already keys its defaults
by) and ChatOptionsPageController.BuildOpacitySliders stamps each
slider's own row caption/tooltip from it — falling back to no text
(never invented English) if resolution fails. Regressed by
ChatOptionsPageControllerTests.
Bind_WiresEachSlidersOwnRowCaption_FromTheResolvedDatCatalog and the
companion Bind_MissingCaption_RendersNoText_NeverInventsEnglish case,
plus a live-mount probe (OptionsPanelLiveMountProbeTests.
ProbeChatOpacityCaptions) confirming the production TryRead call
resolves "Inactive Opacity"/"Active Opacity" against the real DAT.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Root cause: RetailWindowOpacityController applied the Default/Active
opacity fade to EVERY registered UiRoot window (vitals, toolbar,
inventory, spellbook, radar, even the Options panel itself), but
retail's ChatInterface::SetDefaultOpacity/SetActiveOpacity are only
ever called by gmMainChatUI/gmFloatyChatUI — the mechanism is chat-only
in retail, not a global window-opacity feature.
Fix: scope the controller's catch-up loop, OnWindowRegistered,
ReapplyAll, and Dispose to WindowNames.Chat/ChatWindow1-4 only; every
other registered window now stays fully opaque regardless of slider
position, matching retail's own scope. Regressed by
RetailWindowOpacityControllerTests.
OpacityFade_AppliesOnlyToChatWindows_NeverOtherPanels (registers
vitals/toolbar/chat/a floating chat window and asserts the non-chat
windows never move off 1.0 while chat windows still track Default/
Active correctly).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Root cause: DatWidgetFactory builds Config-tab Type-0x10000038 menu
leaves as bare UiMenu instances (matching the vendor/chat channel menu
pattern), but unlike those two controllers, ConfigOptionsPageController
never wired the menu's sprite/font/geometry properties after Bind — so
every dropdown rendered as plain text with no button well, no arrow
cap, and opened no popup on click (#374's fix only corrected click
ROUTING, not the missing chrome).
Fix: ConfigOptionsPageController.ApplyMenuChrome wires every Config-tab
menu row with the SAME retail sprite ids VendorUiController/
ChatWindowController's channel menu already use for this shared popup
catalog (LayoutDesc 0x21000043), verified against the live DAT via
OptionsPanelLiveMountProbeTests' ProbeConfigMenuChrome/
ProbeConfigMenuPopupChrome probes. Regressed by
ConfigOptionsPageControllerTests.MenuRow_SoundFeatures_OpensAndSelects
ThroughRealHitPath_UsingAuthoredPopupGeometry, which drives the real
click-to-open + item-pick path through the authored popup geometry.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Gate-3 screenshot review (user): 'the chat tab looks like it is missing
per window config' — Chat Window 1's header rendered over a void at the
DEFAULT scroll offset because its 260px self-sized filter block
straddled the viewport's bottom edge and UiScrollablePanel hid
straddling rows WHOLE (AP-201's predicted symptom, now user-observed at
scroll position zero, upgrading it from polish to blocking).
By fix time the UI renderer HAD everything needed: UiRenderContext's
clip stack (PushClip/PopClip with rect intersection + per-draw quad
clipping) and UiElement's ClipsChildren hook, already honored by both
the generic draw walk and hit-testing. The fix is therefore exactly the
shape the filing asked for, in the panel itself:
- ClipsChildren => true: children draw and hit-test clipped to the
viewport rect.
- The layout cull keeps any INTERSECTING row Visible (was: fully-inside
only), with a half-pixel margin excluding zero-overlap edge rows;
fully-outside rows stay hidden as the cheap skip.
AP-201 retired in this commit (AP actives 142 -> 141); #371 closed; the
gate script's Chat-tab steps re-written to expect clean edge clipping
and to treat any whole-block vanish as a regression. Pinned by
StraddlingRow_StaysVisible_AndClipsInsteadOfVanishing (the exact gate-3
geometry: header + 260px straddler in a 430px viewport) and
ViewportClipsChildDrawingAndHitTesting (the clipped slice is not
clickable).
Full Release suite: 13,089 passed / 4 skipped / 0 failed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The gate-3 screenshot flight showed the keyboard list still rendering as
overlapping text after the string-resolver + parked-prototype fixes: all
SIX ActionClass pages were visible simultaneously (six stacked ListBoxes
— 'EmotesterSettings' is the Emote and CharacterSettings pages
interleaved) with six dead tab buttons above them. Root cause: OP8's
Bind built every page's rows but never called ActivateTabBehavior() on
the screen's own Type-8 tab host (0x1000049B), so no authored click
bindings were wired and no default-entry switch ran. The authored table
marks Movement (0x1000049D) IsDefault=true — activation now performs the
same default switch OptionsPanelController runs on ITS host, hiding the
other five pages and making the six tabs live.
Regressed by Bind_ActivatesTheTabControl_MovementDefaultShown_OtherPagesHidden
(fixture-driven: BehaviorActive, Movement visible, five pages hidden,
SwitchTo flips exclusivity).
Full Release suite: 13,087 passed / 4 skipped / 0 failed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
User directive (gate 2, verbatim): "mark all options that are not
implemented now, so I can clearly see what is not implemented." Store-only
rows keep full interactivity (still persist/send) but render their caption
in a shared dimmed grey (UiRenderContext.StoreOnlyCaptionColor, matching
the existing UiMenu.TextColorGhosted convention) instead of white/DAT
color. No invented marker text anywhere -- the dim IS the marker.
Config tab (ConfigOptionsPageController, 21 of 27 rows dimmed):
Sound Features menu, Interface Sound trio, Play Sound Only When Active
(AP-199); Screen Brightness, Automatic Degrades, Graphics Performance,
Degrade Distance, the four Rendering Quality menus, Building Detail
Textures, Multi-Pass Alpha (AP-198); Camera Stiffness, Camera Adjustment
Speed, Align To Slope, Mouse Look Sensitivity, Invert Mouselook Y Axis,
Use Mouse Turning (TS-74); Chat Font Face/Size (AP-200). NOT dimmed:
Sound/Ambient trios, Resolution, Full Screen (LIVE), VSync and Field of
View (NEXT-LAUNCH -- still implemented, just deferred to next process
start, per the controller's own doc).
Character tab (CharacterOptionsPageController, 35 of 50 rows dimmed):
every Group A (wire+store only) and Group D (deferred) row, plus the
Group B rows the OP4 gate script's own step 16 confirms are unbound
(ShowTooltips, SideBySideVitals, SpellDuration, AdvancedCombatUI,
StayInChatMode, DisableMostWeatherEffects, PersistentAtDay,
FilterLanguage, MainPackPreferred). NOT dimmed (15 rows): the six
ListenTo*Chat ids (TurbineChatMembershipGate), DisableDistanceFog/
DisplayTimeStamps/ToggleRun (bound at GameWindow.cs), the Group-C
re-point (ViewCombatTarget/VividTargetingIndicator/CoordinatesOnRadar/
AutoTarget/AutoRepeatAttack), and DragItemOnPlayerOpensSecureTrade
(TS-48). Cross-checked against actual shipped consumers via source grep,
not just the research doc's Group table, since OP4 only wired a subset
of the doc's aspirational Group B.
Configure Keyboard (KeyboardConfigController): a row whose
RetailActionIdentityTable lookup fails (MappedAction null -- AP-203's
Emote/CharacterSettings set) dims its synthesized caption; the key
buttons stay fully bindable/persisted/conflict-checked.
Chat tab (ChatOptionsPageController): audited, zero store-only rows --
every filter block and both opacity sliders already have a live consumer
(ChatWindowState / RetailWindowOpacityController).
Ambiguity flagged, not guessed: the character-options-map.md research doc
lists AcceptLootPermits in BOTH Group A and Group C; its only code site
(LiveSessionRuntimeFactory.cs, the /consent command) is a second setter
for the same server bit, not a behavioral reader, so it is classified
Group A / dimmed here.
Register: AD-78 documents the convention (retail dims nothing; this is a
deliberate acdream-only divergence that retires as consumers land).
New per-surface conformance tests pin the exact dimmed/live set against a
literal expected list, so wiring a future consumer without also flipping
its row's literal fails the build:
CharacterOptionsPageControllerTests.StoreOnlyRows_MatchTheDerivationTableExactly
+ Bind_AppliesDimmedCaptionColor_ForStoreOnlyRows_AndWhiteForLiveRows,
ConfigOptionsPageControllerTests.CaptionDimming_MatchesTheStoreOnlySetExactly,
KeyboardConfigControllerTests.UnmappedRows_DimTheirCaption_MappedRowsStayWhite.
Build green; full Release suite 13,086 passed / 4 skipped / 0 failed
(baseline 13,082/4/0 -- delta is exactly the four new tests above).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Campaign OP gate 2: the screen opened as a visual mess (textless
buttons/tabs, buttons above the window, overlapping text) while the
fixture conformance suite stayed green — the #372 class again. Two root
causes, both proven by the new env-gated live-DAT probe before fixing:
1. MountKeyboardConfig's main LayoutImporter.Build was the ONE mount in
RetailUiRuntime not passing strings.Resolve — every AUTHORED caption
(OK/Cancel/Defaults/Revert/Load/Save, the six ActionClass tab labels,
the Command/Mapping column headers) built empty, while the
controller's own resolveString row captions worked, which is why the
screen was recognizable but textless. Fixed by passing the resolver
like every sibling mount.
2. gmKeyboardUI authors its ListBox row templates (header 0x1000002E,
action row 0x1000002F with the three 100x32 key buttons) as TOP-LEVEL
siblings referenced by dat property 0x64. Retail never instantiates
template-list elements as live widgets (AddItemFromTemplateList
clones from the desc — the same re-import UiTemplateListBox's
TemplateResolver performs), but ImportInfos built them parked at the
screen's (0,0): three key buttons at y=0..32 ABOVE the framed panel
(top y=62) — the 'outside the window' buttons — under a 570x40
header text overlapping them and the top chrome. ImportInfos now
skips top-level elements referenced by a SAME-LAYOUT template list
(the same skip class as the existing BaseElement-prototype filter;
same-layout only because element ids collide across layouts —
0x10000211 is a page in BOTH the options and keyboard layouts).
The probe (ACDREAM_PROBE_LIVE_MOUNT=1) pins both against the real DATs:
prototypes absent from the built tree, and Defaults/Revert/OK/Cancel
resolving on the resolver-passing build. Post-fix the import collapses
to the framed 600x476 panel with every screen button inside its bounds.
Full Release suite: 13,082 passed / 4 skipped / 0 failed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Campaign OP gate 2 root cause: UiElement.HitTest walks siblings
front-to-back by z-order, so an OPEN UiMenu's extended button+popup
hit-test union was never consulted when a LATER sibling's rect overlapped
the popup area — on the Config tab every dropdown has rows below it, so
Resolution-item clicks toggled the Full Screen / VSync rows underneath
(the gate session's persisted fullscreen/vsync flips were exactly those
stolen clicks). Latent since UiMenu existed; vendor/chat menus only
worked by z-order luck.
Fix: UiMenu's open/close now registers with UiRoot (SetActivePopup /
ClearActivePopup); a registered popup gets FIRST claim on mouse-down,
scroll, and hover routing; a press outside a live popup dismisses it and
is SWALLOWED (the dismissing click must not act on what sat underneath);
hidden/detached owners self-heal the registration on the next pointer
event. UiMenu gains the IsOpen seam and a single SetOpen writer.
Also in this commit, from the same investigation:
- SilkRuntimeDisplayWindowTarget.Apply documents the fullscreen half
honestly: IViewProperties.VideoMode is READ-ONLY, so a resolution pick
while fullscreen cannot switch the display mode through Silk's
abstract API — split out as #376 (native glfwSetWindowMonitor port)
rather than half-shipping untested native interop at a gate tail.
- Gate script §OP6 step 8 re-scoped: test resolution in WINDOWED mode.
Regressed by tests/AcDream.App.Tests/UI/UiMenuPopupRoutingTests.cs —
4 tests driving the real UiRoot input path on a mounted overlapping
tree, with an in-test overlap CONTROL click so the popup assertions
cannot pass vacuously (the #372 lesson: only mount+drive-input tests
catch this class; every fixture-conformance test stayed green through
this bug).
Full Release suite: 13,081 passed / 4 skipped / 0 failed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The campaign's /goal stop condition is reached: all nine slices landed and
reviewed (OP1/OP2/OP7/OP9 CLOSED; OP3-OP6 + OP8 code-complete with
connected gates owed), and the gate script is the complete per-tab
connected-gate contract (launch with ACDREAM_RETAIL_UI=1; §OP7 already
PASSED live bot-vs-ACE). OP9's ledger row records the combined review
chain (289bf5bc APPROVE-WITH-FIXES -> residuals 07f2b3f7) including
SF-4's corrected test-delta arithmetic (-84, not the implementation
commit's '-80 exactly'). CLAUDE.md Current-state gains the campaign
paragraph per feedback_claude_md_staleness; the settings digest
(claude-memory/project_settings_options_digest.md) is the new domain
entry point.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Closes the OP9 combined review's findings (docs/research/2026-08-11-op9-review.md,
APPROVE-WITH-FIXES):
- MUST-FIX 1: SaveAudio -> ApplyAudio (OP6's Config-tab live-apply) lost
its ONLY assertion when the retired SettingsVM save-order test was
deleted. Restored directly on the now-public seam:
SaveAudioPersistsThenPushesLiveApplyAudioWithTheSavedSnapshot pins
persist-then-push order + the pushed snapshot;
SaveAudioSkipsTheLivePushWhenPersistenceFails pins the failure ordering
(a failed persist pushes nothing and commits nothing). Also closes
SF-5: the OP6 effective-volume comment's 'target-audio assertion above'
reference is real again and now names the restored test.
- SF-3: dead residues deleted — RuntimeSettingsController's private
SaveCharacter (zero callers post-371197a3), ISettingsStorage.SaveCharacter
+ its JsonRuntimeSettingsStorage/FakeStorage implementations (the deleted
private method was the only caller), and IngressShutdownRoots.Settings
(zero readers since the view-model shutdown stage died). SettingsStore's
PUBLIC SaveCharacter stays: it is the tested storage-API seam, and
per-toon entries in existing settings.json files still load through the
live LoadCharacter path.
- SF-2: code-structure.md's presentation-seam list no longer routes the
settings preview through 'optional SettingsVM'.
- NIT 6: AP-196's retirement note now attributes LockUI (/lockui +
PlayerDescription SetUiLocked convergence) and UseMouseTurning
(Gameplay-tab macro + Config-tab row) to their real channels instead of
folding all 13 members into the Character tab.
Full Release suite: 13,077 passed / 4 skipped / 0 failed (13,075 + the two
restored tests). One unnamed App-assembly failure appeared on the first
post-fix full run and did not reproduce on the isolated assembly rerun nor
a second full run — consistent with the known #250-class parallel-load
flake, recorded here for honesty rather than silently rerun.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Combined mechanism-faithfulness x regression/blast-radius pass over
371197a3 (OP9 retirement of the dead F11 settings surface +
GameplaySettings), per the OP5/OP7 single-reviewer precedent for
closeout-shaped slices.
Retirement verified correct: zero production readers of GameplaySettings
or SettingsVM existed pre-commit (checked against the pre-commit tree,
not the diff), SetUiLocked first-call/repeat-call behavior is provably
unchanged by deleting _uiLockConverged, the wire paths
(SetAcceptLootPermits 0x0005, ToggleUiLock, PlayerDescription
convergence) are untouched, the settings.json unknown-key carry-forward
is real, and the AP-196 register edit reconciles (143 -> 142 active,
29 -> 30 retired, total unchanged).
MUST-FIX 1: SaveAudio -> ApplyAudio (OP6 live-apply, live consumer in
ConfigOptionsPageController) lost its only assertion when
SettingsViewModelSavePreservesSectionAndTargetOrder was deleted.
SHOULD-FIX 2-5: stale architecture-doc seam naming SettingsVM; three
dead residues (uncallable private SaveCharacter, orphaned
IngressShutdownRoots.Settings, writerless CharacterSettings path);
test delta enumerates to -84, not the claimed -80 "exactly"; dangling
comment referencing the deleted assertion. Two nits.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Campaign OP slice OP9 code retirement (docs/plans/2026-08-10-options-panel-campaign.md
§OP9). The retail four-tab Options panel (OP1-OP8) is now acdream's one
in-client settings surface (D1) — this commit deletes the pre-retail-UI
surfaces it fully superseded. Pure retirement: no behavior change to
anything live, verified by dispositioning every one of the 15 src files
and 8 test files that referenced the deleted types before touching any
of them.
1. SettingsVM + SettingsPanel (the old F11 IPanel surface, unrendered
since Campaign V slice V11's ImGui deletion) deleted outright, plus
their two dedicated test files. IPanel/IPanelRenderer stay — ChatPanel/
DebugPanel/VitalsPanel still implement IPanel, so the contract does NOT
become unreferenced.
2. RuntimeSettingsController's SettingsVM binding seam deleted:
CreateViewModel, CreateViewModelBinding, RuntimeSettingsViewModelBinding,
the _viewModel field, UnbindViewModel, and every _viewModel? notification
call (ToggleFrameRate, LoadCharacterContext, RestoreDefaultCharacterContext,
SyncChatFromServerOptions). CreateViewModelBinding had zero production
callers (test-only). HasDraftPreview/DisplayPreview/AudioPreview stay on
IRuntimeSettingsPreviewSource (WorldRenderFrameBuilder and
SettingsParticleRangeSource still consume it) but now trivially mirror
the committed Display/Audio snapshot — HasDraftPreview was already
always false in production. GameWindowLifetime's matching
"settings view model" shutdown stage is deleted.
3. IDevToolsGameplayCommands + DevToolsGameplayCommands deleted from
GameplayInputCommandController.cs. All three members were dead:
ToggleSettingsPanel() had ZERO dispatch sites (ToggleOptionsPanel always
routed to _retained, never to _devTools); ToggleDebugPanel()/
FocusChatInput() had dispatch sites (F1/Ctrl+F1, Tab) but empty no-op
bodies (their ImGui DebugPanel/ChatPanel targets were already gone).
The two live dispatch sites are kept as inert `return true;` cases
(still consuming the key, matching the prior no-op's "handled"
contract) instead of falling through to a lower-priority scope.
SessionPlayerComposition.cs's `new DevToolsGameplayCommands()`
construction is removed. No `ToggleSettingsPanel` InputAction exists
(only `ToggleOptionsPanel`, rebound at OP3) — nothing to remove there
or from KeyBindings.RetailDefaults()/keymap fixtures.
4. GameplaySettings deleted entirely (the type, SettingsStore's
LoadGameplay/SaveGameplay/BuildGameplayObject, RuntimeSettingsController's
Gameplay property/SetAcceptLootPermits). Verified all 13 remaining
members (ToggleRun, AdvancedCombatUI, ShowTooltips,
VividTargetingIndicator, SideBySideVitals, CoordinatesOnRadar,
SpellDuration, AllowGive, ShowHelm, ShowCloak, LockUI, UseMouseTurning,
AcceptLootPermits — the three combat ones already died at OP4/AP-196)
were ALREADY bound through CharacterOptionTable/
CharacterOptionsPageController's server-bit seam at OP4 before deleting
the client-local mirror — no (c)-case genuinely-client-local member
was found; disposition (b) covers 100% of the surface. SetUiLocked
rewritten to compare only against the last value actually pushed to
_runtimeTargets (MUST-FIX 4's guard), with no second store left to
read or write. LiveSessionRuntimeFactory's SetAcceptLootPermits binding
now sends the wire option only (the GameplaySettings write-behind call
removed as dead output). CharacterSettings/DisplaySettings/
AudioSettings/ChatSettings and their SettingsStore Load/Save surfaces
are UNTOUCHED per the campaign contract.
Per-file disposition (15 src + 8 test files that referenced
GameplaySettings before this commit):
- GameplaySettings.cs, SettingsVM.cs, SettingsPanel.cs: the types
themselves — deleted.
- SettingsStore.cs, RuntimeSettingsController.cs,
LiveSessionRuntimeFactory.cs: real usage — API deleted/rewritten.
- RetailUiRuntime.cs, InteractionRetainedUiComposition.cs,
SessionPlayerComposition.cs, CombatUiController.cs,
LiveCombatAttackOperations.cs, LivePresentationComposition.cs,
FrameRootComposition.cs, RuntimeCharacterState.cs,
CombatCameraTargetSource.cs: doc-comment-only or interface-name
substring matches (ICombatGameplaySettingsSource) — left as accurate
historical record, no forward reference to the deleted type.
- Tests: RuntimeSettingsControllerTests.cs and SettingsStoreTests.cs
rewritten (Gameplay-specific tests deleted; SaveDisplay/SaveAudio/
SaveChat tests re-targeted off the now-public methods instead of the
retired SettingsVM draft/Save() indirection); GameplaySettingsTests.cs/
SettingsVMTests.cs/SettingsPanelTests.cs deleted; the remaining three
(CharacterOptionCombatSettingsSourceTests.cs,
CombatCameraTargetSourceTests.cs, LiveCombatAttackOperationsTests.cs)
were comment/interface-name-only, untouched.
5. Register: AP-196 (OP4's partial GameplaySettings retirement, which left
five fields as write-behind mirrors) is fully retired now that the
record is gone outright — marked ~~AP-196~~ RETIRED with its retirement
note, active-row count 143 -> 142. No other row cited the deleted types
directly (AP-194/AP-193 cite CharacterOptionTable.cs, not
GameplaySettings.cs).
6. Settings.json migration honesty: SettingsStore no longer reads or
writes the "gameplay" top-level key, so an existing file carrying one
from a pre-OP9 build is neither parsed nor dropped — the existing
SaveSection raw-JSON-text preservation mechanism (unknown top-level
keys survive every subsequent save) carries it forward untouched.
Two new targeted tests
(LeftoverGameplaySection_FromAnOlderSettingsJson_DoesNotBreakOtherLoads,
LeftoverGameplaySection_SurvivesAnUnrelatedSave) pin this.
InputAction.ToggleOptionsPanel's stale doc comment (still describing the
retired ImGui SettingsPanel) and a handful of other dangling doc
references (DisplaySettings.cs, ChatOpacityLink.cs,
SettingsDevToolsComposition.cs, InputDispatcherCaptureTests.cs) are
reworded to point at the current retail Options panel / OP8
KeyboardConfigController.
Build: dotnet build -c Release green, 0 errors. Tests: dotnet test -c
Release --no-build — 13,075 passed / 4 skipped / 0 failed (13,079 total),
down from the stated baseline of 13,155 passed / 4 skipped / 0 failed
(13,159 total) — the -80 delta is exactly the deleted SettingsVM/
SettingsPanel/GameplaySettings test surface (three whole files plus the
Gameplay-specific cases trimmed from RuntimeSettingsControllerTests.cs/
SettingsStoreTests.cs), with zero regressions elsewhere.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Merge 1c5cd969 lands OP8 on the campaign tip after 057d8cd7, so the
#372 viewport fix covers OP8's six ListBoxes (the re-review's merge
precondition). Post-merge full Release suite: 13,155 / 4 skips / 0
failures. #373 captures the deferred DAT ActionMap.ConflictingMaps
consultation the OP8 round-2 conflict-universe fix scoped out.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Brings b4edee97 (slice), b1968ce9 (M1/M2/M3 rework), f1d50207 (round-2
residuals). Review chain: REJECT -> rework -> REOPEN-narrow -> coordinator
third round; findings docs 2026-08-11-op8-review.md / -op8-rereview.md.
The merge lands OP8's six ListBoxes on top of 057d8cd7's #372 viewport
fix, which auto-heals the blank-pages hazard the re-review flagged — the
OP8 connected gate was contracted to run post-merge for exactly this.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
R1 (code half): store-only rows (MappedAction null) are excluded from the
conflict universe — they never reach the InputDispatcher, so a chord they
display cannot collide; counting them made the ten Camera Alternate
arrow-key defaults trip a false N-way confirm on any arrow rebind. Mapped
cross-context sharing (retail's ConflictingMaps — the combat cluster)
remains deferred as ISSUES #373 with the OP8 gate script now carrying the
explicit do-not-file warning. SHOULD: unmapped rows with no persisted
chords display their DAT defaults (retail shows the arrow keys; blank
read as 'unbound') — display-only, the store is untouched until the row
itself is edited; the independence test updated to pin the new display
semantics while keeping its storage-isolation asserts. Injectivity of
RetailActionIdentityTable is now test-enforced (load-bearing for both M1's
per-row activation capture and M2's de-alias). R2: AP-203 addendum names
the ten same-verb-sibling-live rows and the conflict exclusion.
Full Release suite in this worktree: 13,155 passed / 4 skips / 0 failed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
All three MUST verified mechanically: the one-pair-per-action capture
claim PROVEN against RetailDefaults() (9 multi-binding actions, no
counterexample); the identity map now injective (all 122 entries
checked); the confirm dialog real, N-way, accept-only, with the lazy
read's null unreachable in production. The OK/Cancel gesture divergence
is honestly retired (no distinguishing retail affordance = no Risk
symptom to write).
Coordinator third-round residuals: R1 — S2's deferral is commit-message-
only and M2 worsened it (0x6's DAT defaults are the arrow keys, so
Defaults + the flat conflict universe makes 'Move Forward -> Up' trip a
false N-way confirm; the gate script carries no warning). R2 — AP-203's
row body doesn't cover the ten same-verb-sibling-live rows. SHOULD: the
ten Camera Alternate rows render BLANK (retail shows the arrow keys);
identity-map injectivity is load-bearing for M1+M2 with nothing
enforcing it. MERGE NOTE: textually clean vs the campaign tip, but
OP8's six ListBoxes sit on the #372 viewport path — blank on the branch,
auto-healed by merging onto 057d8cd7+; the OP8 gate runs POST-merge.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The first connected gate reported most Gameplay buttons 'did nothing';
two of seven are contract-inert and the other four have dialog/chat
effects that can go unnoticed. One line per click at the BindButton
chokepoint makes the next gate's log a definitive fired/not-fired
record per button — evidence before investigation, per the debugging
discipline. App suite green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The 13 ID_ChatOption_TextFilter_* labels (and their _Desc tooltips) live
in string table 0x2300000D, not the 0x23000003 options table the section
headers and slider labels use. Dat-verified: the targeted sweep missed
(0x23000001-0A), the control key resolved ('Auto Target' — machinery
fine), and the exhaustive all-tables sweep (ProbeFilterLabelHome, now a
permanent env-gated probe) hit exactly once: 0x2300000D -> 'Combat'. The
initializer decomp confirms the hash KEYS are the symbol names verbatim
(the vftable-member operands at 0x006f04cd are the known pooled-string
artifact); only the research doc's table attribution was inferred rather
than dat-verified — corrected in §8.
All 13 rows now render their captions instead of the honest-fallback
blanks the first connected gate saw. Full Release suite green (one
Core.Net loss-simulation timing flake on the first run, green targeted
and on rerun).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ROOT CAUSE (proven, not guessed): the lazily-created row viewport was
constructed 0x0 with Left|Top|Right|Bottom fill-anchors. Its first
ApplyAnchor captured mR = parentW - (0+0) = parentW, so
ComputeAnchoredRect's l&&r branch (w = parentW - mR - mL) kept it 0x0
forever. A 0-tall viewport makes UiScrollablePanel.LayoutScrollableChildren
cull every row, so Character/Chat/Config rendered blank while Gameplay
(no viewport — authored static children sized at Build) worked. This is
the exact Gameplay-vs-rest split the user's first connected gate found.
FIX: seed the viewport to the ListBox's current extent at creation, so the
fill-anchor baseline is mR = parentW - parentW = 0 and the viewport tracks
the parent. The ListBox is a static dat child sized at Build, so its extent
is authored by the time the viewport is lazily created during Bind.
Dormancy preserved — the viewport is still created only on the first row.
Reproduced RED then GREEN by UiTemplateListBoxViewportTests (viewport fills;
rows stay visible after the anchor+cull layout pass) — the layout path the
whole fixture conformance suite structurally never drove, which is why
every OP2-OP6 test was green over a live-only blank-tab failure. Full
Release suite 13,131 / 4 skips / 0 failed.
Still owed (NOT fixed here, no evidence yet): the 'only Exit Game worked'
Gameplay-buttons observation needs a re-gate (two buttons are correctly
INERT; the other four have dialog/chat effects that may have gone
unnoticed); and the 13 ID_ChatOption_TextFilter_* labels fail to resolve
(blank captions, behaviour unaffected). See #372.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fixes the three MUST-FIX findings from the 2026-08-11 combined dual-lens
review of commit b4edee97 (docs/research/2026-08-11-op8-review.md).
M1 — SetForAction destroyed ActivationType/InputScope on every write,
collapsing walk-mode's Hold, the three combat-scoped bindings, and
CameraInstantMouseLook's mouse chord the instant a row (including
Defaults, which touches all ~140 mapped rows at once) wrote back.
Widened the Bindings seam to carry the full Binding (chord + activation
+ scope), not a bare chord: KeyboardConfigController captures each
row's live Activation/Scope ONCE at build time (every multi-chord
action in KeyBindings.RetailDefaults() shares one pair across all its
bindings) and reapplies it on every write — rebind, Cancel/Revert, and
Defaults (which restores DAT-sourced KEYS only, never touches the
pair). New tests pin this across both Defaults and Cancel for a
Hold+MeleeCombat-scoped action.
M2 — InputMap 0x5 (CameraControls) and 0x6 (CameraAlternateControls)
aliased one InputAction each: both rows read/wrote the same live target,
so they showed identical stale chords, a rebind of one silently wiped
the other, and a row could conflict with its own twin. Building real
per-scheme dual-binding storage (or new InputAction members plus the
camera-dispatch code to consume them) is a feature, not a one-line fix.
Chose the third option: only ctx 0x5 — the scheme RetailDefaults()
actually has live support for — maps to InputAction; ctx 0x6 falls
through to the existing unmapped/store-only path (AP-203), fully
rendered, bindable, and persisted, honestly carrying no live effect.
This also retired 10 stale allowlist entries in the DAT-vs-
RetailDefaults() round-trip test: with the alias gone, ctx 0x5 alone
matches RetailDefaults() exactly for all twelve Camera actions.
M3 — the auto-reassign-on-conflict path was wired silent in production
(NotifyReassigned: _ => "") though the contract asked for a prompt and
retail confirms before overwriting (OpenOverwriteBindingDialog). Wired
a real confirm dialog through RetailDialogFactory.MakeConfirmation —
the same seam GameplayConfirmationController already uses — read
lazily since DialogFactory mounts after MountKeyboardConfig in
Initialize()'s order. Only reassigns on accept; decline leaves every
row untouched. AP-204 (which recorded the narrowing) is RETIRED; the
still-true OK/Cancel left-click-vs-right-click-release note moves to a
code comment (zero observable difference, doesn't warrant a register
row). Reverted the gate script's step 9 from documenting the silent
shape back to the real confirm-prompt behavior.
SHOULD-FIX addressed as one-liners in files already touched:
- S1: non-user-bindable conflicts are now checked BEFORE any row
conflict (retail's own order), and ALL conflicting rows are collected
(N-way), not just the first match.
- S3: Save wraps the file-write pair in the same try/catch
RuntimeKeyBindingTarget.Apply already uses for keybinds.json.
- S4: assigning "Mapping 3" on a row with no existing bindings now
lands on display index 2, not index 0 — ReplaceSlotValue trims only
TRAILING empty slots instead of stripping every default(KeyChord).
Right-click on an already-empty slot is now a no-op instead of
shifting later bindings.
- S6: UiButton.OnRightClick returns false (unhandled, bubbles to
parent) when no handler is set, disabled or not — matching the
pre-existing behavior the class doc already claimed.
Left for a future pass (not one-liners): S2 (ActionMap.ConflictingMaps
is still unread — the conflict scan treats all 306 rows as one flat
universe instead of respecting the DAT's own legitimately-shared-key
table) and S5 (the ~330 DAT layout imports still run eagerly at mount
instead of lazily on first open).
19 KeyboardConfigControllerTests (was 12): +2 activation/scope
preservation (Defaults, Cancel), +1 camera de-alias, +2 confirm-dialog
accept/decline, +1 non-bindable-takes-priority-over-row-conflict, +1
sparse-row third-slot placement. Full solution suite 13,154 passed / 4
skipped / 0 failed (this round's baseline 13,147/4/0, zero regressions).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The user's first Campaign OP connected gate (ACDREAM_RETAIL_UI=1, live
ACE) failed: Character/Chat/Config tabs render blank, only Exit Game of
the seven Gameplay buttons visibly acted. A live-DAT mount probe
(committed, env-gated) proves the panel BUILDS completely — root
UiTabPanel with a 4-entry tab table, all four page slots, all three page
ListBoxes as UiTemplateListBox with row templates, all seven buttons as
UiButton — and the three page controllers' Bind() run at mount. So the
defect is in the live render/input path the whole fixture suite never
exercises (mount -> ActivateTabs -> tab-click SwitchTo -> row draw /
button hit-test): the OP2-blast structural-false-negative class. Blocks
the OP3-OP6 gates; needs a dedicated debug slice + a gate-representative
test, not a guess.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
M1: SetForAction rebuilds bindings as new Binding(chord, action),
defaulting Press/InputScope.Game and dropping the read chord's
ActivationType/InputScope — MovementWalkMode (Hold), the three melee
heights (Hold+MeleeCombat), Missile/Magic-scoped rows, and
CameraInstantMouseLook (Hold, Device 1) all collapse; RestoreDefaultValue
invokes _apply so ONE Defaults click flattens ~140 actions and OK
persists it (gate step 12 tells the user to click Defaults). M2: the ten
camera 0x5/0x6 rows alias one InputAction each — 20 rows over 10 targets,
twins seed identically, clobber each other, self-conflict; the
round-trip test justifies the dual map on independence the mechanism
makes impossible. M3: silent auto-reassign (NotifyReassigned -> empty,
DisplaySystemMessage skips empty) destroys a binding with zero feedback
while the doc claims it reports; retail confirms first
(OpenOverwriteBindingDialog), the plan gate says 'conflict prompt', the
tested SettingsVM prompt precedent exists, AP-204 concedes
RetailDialogFactory is wired, and a fake asserts a message production
contradicts. SHOULD: non-bindable check ordered after first-match
conflict (inverts retail's any-non-bindable-refuses); ActionMap.
ConflictingMaps never read (false conflicts on legitimately-shared
combat keys); Save lacks the writer's try/catch; sparse-row Mapping-3
lands on Mapping-1; ~330 uncached layout imports at startup.
Clean: the 0x26000000 DID (better-anchored than cited — .actionmap
MasterDBMap range, 0x27 is the type tag); no-Clear-button confirmed
against the fixture; six-box grouping matches lane D; identity
byte-comparison is a permanent test; modal capture real;
AP-202/203/204 accurate; OP3 INERT retired. Brief-premise correction:
ff577641 is the CAMPAIGN tip (claude/latest-commits-cb0c8f), not main's
(852cdda7); OP8 additive, only OptionPageModel.cs is a triple-append
merge watch-point.
Rework round 1 to the isolated op8-keyboard worktree.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Ports retail's Configure Keyboard screen (gmKeyboardUI, LayoutDesc
0x21000009) — its own separate full-screen window, not a fifth Options-
panel tab. Retires OP3's INERT contract for the Gameplay tab's Configure
Keyboard button (0x10000204).
DAT reader (src/AcDream.Core/Input/RetailActionMap.cs): reads the
ActionMap singleton (DID 0x26000000, empirically the only one — not
0x27000000 as GetDBOType's Turbine-internal tag would suggest) and both
MasterInputMap defaults (0x14000000 "gmDefaultMap"/0x14000002
"DefaultMap"), union-merged per (InputMapId, ActionId) — proven order-
independent since the two maps' one shared context (0x5) has disjoint
action-id sets. Empirically resolved three lane-D unknowns against the
live DAT: the six ActionClass values (1=Movement, 2=Camera, 3=UI,
4=Combat, 5=Emote, 7=CharacterSettings — 6 is genuinely absent), that
the six unnamed InputMaps are 100% non-bindable (render nothing, not an
unlabeled group), and that the enum-to-DID pairing for the two master
maps is inconsequential to the merge result.
Identity table (src/AcDream.UI.Abstractions/Input/RetailActionIdentityTable.cs):
maps DAT (InputMapId, ActionId) pairs to acdream's InputAction where a
live consumer exists (~140 of 306 user-bindable rows — Movement/Camera/
Combat map almost completely; UI/Quickslot/Chat partially; only 5 of 87
Emotes and none of 48 CharacterSettings hotkeys, since acdream has no
general emote player or hotkey-to-option-toggle dispatcher yet). Every
entry cross-verified by label match AND a DAT-default-vs-
KeyBindings.RetailDefaults() byte comparison (RetailActionIdentityRoundTripTests),
which caught a real off-by-one in the Quickslot 13-18 block before it
shipped and found three genuine pre-existing RetailDefaults() gaps
(walk-mode's Shift-echoed chord, ten CameraAlternateControls arrow-key
alternates, and the Quickslot Ctrl+N use-vs-select ambiguity) — none
introduced by this slice, all documented rather than silently patched.
KeyboardConfigController: six ActionClass list boxes built from the
DAT, merged with live KeyBindings for mapped rows (rebind applies
immediately through the same InputDispatcher every other input path
uses) and a new sibling RetailUnmappedKeyBindings store for rows with
no InputAction yet. Left-click a key button opens real InputDispatcher
modal capture; right-click erases that slot. N-way conflict detection
scans every other row plus the live KeyBindings table for acdream-only
actions (Ctrl+M mute, debug F-keys) as the non-user-bindable refusal
analogue, using retail's own byte-verified "Could not overwrite "
string (table 0x23000004). OK/Cancel/Defaults/Revert reuse the
OptionPage/IOptionRow verb model via a new ActionKeyMapOptionRow.
Persistence is keybinds.json only (D4 — no .keymap file interchange).
Five register rows: AP-202 (.keymap interchange narrowing), AP-203
(store-only rows with no live consumer), AP-204 (silent auto-reassign
instead of retail's confirm dialog; OK/Cancel ported as left-click not
right-click-release).
Small supporting additions: UiButton.OnRightClick (additive, no
existing behavior changed), InputDispatcher.Bindings getter (the
screen's single live-truth read seam), RetailScanCodeMap (DIK scan
code <-> Silk.NET Key, keyboard + the one mouse-device row).
19 new tests (6 ActionMap reader conformance incl. live-DAT row-count/
label pins, 1 DAT-vs-RetailDefaults round-trip, 12 controller
behavior tests against the committed keyboard_config_21000009.json
fixture) — full solution suite 13,147 passed / 4 skipped / 0 failed
(baseline 13,128/4/0, zero regressions).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The re-reviewer byte-decoded ALL six caption sites independently (no
transposition; a third evidence line from the fixture's authored
left/right label geometry), re-verified the audio chain through the one
place an inversion could still hide (the checkbox pass-throughs), and
hand-traced the 97-key conformance table (exactly 97, none invented or
dropped). Residuals applied here: SF-1 the AudioSettings doc comment's
wrong function attribution (the SetDefaultValue literals live in
gmConfigUI::InitOptions @0x0049E435/E457/E479, not InitUIPreferences);
SF-2 the gate script no longer asserts an unread DAT caption — it gates
on behaviour and asks the tester to report the authored English
verbatim; lane A's stale '24 option rows' corrected to 27 (the 39-item
pin is the authoritative tally). AudioSettings.cs change is
comment-only (no executable-code delta; suite state carries from
67b0815c, re-verified at the next code commit).
Campaign state: OP1-OP7 ALL CODE-COMPLETE; OP3/OP4/OP5/OP6 gates ready;
OP8 (Configure Keyboard) is the sole remaining implementation slice.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
R1: UiRoot now delivers WM_CAPTURECHANGED (0x215 — retail's own Win32
event-id space) to the element losing pointer capture on BOTH release
and re-target; UiScrollbar terminates a mid-drag gesture there,
completing it (one DragCompleted flush persisting the user's last-seen
value) and unlatching IsDragging — a panel-close keybind mid-drag or a
second-button re-target can no longer latch the drag flag forever and
silently suppress every later settings flush. Normal MouseUp paths
no-op (the latch is already clear when capture releases).
R2: the scalar latch arms BEFORE the track-click jump applies, so the
jump's own ScalarChanged tick defers its flush to the MouseUp's single
DragCompleted — one flush per press gesture, never the
inline-then-completed double; the DragCompleted doc now states the real
contract (fires once per value-capable gesture incl. capture loss)
instead of the refuted never-on-jump claim.
Tests: capture-loss mid-drag (ends + completes once + stray-MouseUp
no-double), no-drag capture-change no-op, bare-track-click
single-completion with the latch observed armed during the jump tick.
Also reconciles the research doc's U4 row to its closure (the six
caption pairs, the BN zero-fold post-mortem) per the OP6 rework's flag.
Full Release suite: 13,128 passed / 4 skipped / 0 failed (one
documented #250-class allocation flake on first run, green in
isolation and on full-suite rerun).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fixes all three MUST-FIX findings from the OP6 REJECT review
(docs/research/2026-08-11-op6-review.md) plus its SHOULD-FIXes and NOTEs.
M1 — the "retail ships zero range captions" claim was a Binary Ninja
constant-folding artifact (the same class the header-string globals a few
lines above already worked around). The six SetSliderLabel call sites
byte-decode to reads of runtime-filled ID_Graphics_Value_* globals, not
immediate zeros (PE-byte-verified against the PDB-paired acclient.exe,
independently re-derived in this session, not just re-asserted from the
review). ConfigOptionsPageController.BuildSliderRow gained optional
rangeLowKey/rangeHighKey parameters wired for all six idx6 sliders (Camera
Stiffness Soft/Hard, Adjustment Speed Slow/Fast, FOV Narrow/Wide, Screen
Brightness Dark/Bright, Graphics Performance Speed/Detail, Degrade Distance
Close/Far) via the same SetRangeLabel mechanism OP5's Chat opacity sliders
already established. Mouse Look Sensitivity (idx3) correctly stays
uncaptioned — the one genuine SetSliderLabel omission. Class doc corrected;
gate-script lines 535/653-equivalent corrected in place.
M2 — the three Sound "Disabled" toggles were semantically inverted:
SoundManager::effect_sounds_enabled/ambient_sounds_enabled/
interface_sounds_enabled are all compiled = 1 in .data, and
UserPreferences::RegisterPreference binds the checkbox's boolean value
DIRECTLY onto those enabled-sense statics — checked-by-default means
enabled-by-default, not disabled. AudioSettings.SfxDisabled/AmbientDisabled/
InterfaceDisabled renamed to SfxEnabled/AmbientEnabled/InterfaceEnabled
(fresh JSON keys — the rejected slice's keys never shipped in an accepted
build); RuntimeSettingsStartupTargets.ApplyAudio now computes effective
volume through the extracted, independently-unit-tested pure function
ComputeEffectiveCategoryVolumes (enabled ? slider : 0f). This closes the
blast radius the review flagged: a missing key in an EXISTING settings.json
now falls back to AudioSettings.Default, which is enabled=true, so a fresh
launch is audible, not muted. AP-199's wording and gate-script step 6
corrected; the enshrined-inversion test rewritten to assert the correct
default and a new SettingsStore test pins the legacy-file fallback path.
M3 — UI_ChatFontFace now ships all five of retail's authored choices
(Arial, CourierNew, PalatinoLinotype, Tahoma, TimesNewRoman — a fixed
compile-time array at gmClient::InitUIPreferences, PE-byte-verified
present verbatim in .rdata, not a per-machine runtime enumeration as the
rejected slice's comment claimed). Default index 2 (PalatinoLinotype) now
indexes a real entry.
S1 — Bind() now emits the sixth trailing AddSeperator retail's own
InitOptions ends with (0x0049E80D), matching retail's 39-item ListBox (6
headers + 6 separators + 27 option-widget-rows) instead of 38.
S2 — Screen Brightness gets its own DisplaySettings.ScreenBrightness field
([-1,1], default 0) instead of overloading Gamma, which has a different
unit system (default 1.0, legacy [0.5,2.0] slider) and its own live
Settings-panel consumer.
S3 — UiScrollbar and UiMenu gained a settable TooltipText surfaced through
GetTooltipText (UiButton's existing pattern). Every slider and menu row's
own interactive widget (not just toggle/trio rows) now carries retail's
"<label>_Help" tooltip, verified as a universal suffix convention across
every AttachPreference site touched by this tab.
S4 — "800x600" added to DisplaySettings.AvailableResolutions: a genuine
retail display mode (Device::ForceDisplayResolution(1,0x320,0x258) at
startup) and the Config tab's own byte-verified Resolution row default, not
an invented preset. Defaults now lands on a highlighted, re-selectable
dropdown entry instead of an orphaned value.
S5 — four new/extended tests: ComputeEffectiveCategoryVolumes gets a
dedicated pure-function value assertion (Theory + a default-profile-is-
audible Fact) in RuntimeSettingsControllerTests, closing the "only event
order was asserted" gap that let M2 ship; a label/choice-key conformance
table in ConfigOptionsPageControllerTests enumerates every key this tab
queries (traced directly from the fixed code paths, not guessed) and fails
on an invented OR a dropped key; a per-row DefaultValue pin asserts every
row's default against the retail literal directly, independent of the
underlying settings-record defaults; and the S1 separator fix gets its own
39-item stacked-ListBox count pin.
NOTEs — AP-198's row count was always ten (its own enumeration never said
nine); the commit-message inconsistency N1 flagged is reconciled in both
the row and the section-summary line, and its Screen Brightness sub-clause
now matches S2. N2: Bind() now reads the scrollbar id from
UiTemplateListBox.ScrollbarElementId (dat property 0x72) instead of a
hardcoded constant. N3 (batch Defaults writes) and N4 (AfterApply on
Config-tab entry, needs no action) are left as recorded — out of this
rework's scope per the review's own disposition.
Full Release suite: 13,125 passed / 4 skipped / 0 failed (baseline
13,117/4/0 — net +8 tests added, 0 regressions, 0 removed).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
All findings genuinely closed: the own-slider push verified against the
never-clamp invariant (mid-drag no-op by construction); the DragCompleted
single-fire proven at all three MouseUp sites with a STRONGER capture
argument than the fixer's (pointer capture + Reset needing its own
click); the S3 review-correction verified from git (the round-trip test
existed at e71e5a96 — the review was wrong, the commit right).
Coordinator residuals (third-round, mine): R1 — _draggingThumb clears
only at MouseUp, but UiRoot can drop capture without one (panel-close
keybind mid-drag; second-button re-target), latching IsDragging true
forever so Reset/Defaults apply live but never persist; the
UiRoot.PointerCaptureChanged seam exists for the root fix. R2 — a bare
track click sets _draggingThumb unconditionally in OnScalarEvent, so it
double-flushes and refutes the DragCompleted doc's never-fires-on-jump
claim. Five NOTEs recorded incl. the Chat-side de-scoping blind spot.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fixes the OP5 (Chat tab) dual-lens review findings against e71e5a96:
- M1 (MUST-FIX): each opacity row's own apply closure now pushes its OWN
slider's thumb from the post-link truth (bindings.Current*Opacity()),
mirroring the OP4 binding pattern. Before this, a single-slider drag
followed by Reset reverted the live value/link but left that slider's
own thumb stuck at the dragged position.
- S1 (SHOULD-FIX): the Chat tab's two opacity sliders no longer round-trip
the whole settings.json on every drag MouseMove tick. UiScrollbar gains
IsDragging + a DragCompleted callback (fires once, at the MouseUp that
ends an actual thumb drag); the opacity apply closures flush immediately
when not mid-drag (Reset/Defaults/discrete edits, same as before) and
defer to DragCompleted otherwise, collapsing dozens of per-tick writes
into exactly one per drag gesture. Live opacity still applies every tick.
- S2 (SHOULD-FIX): filed register row AP-201 and issue #371 for the
UiScrollablePanel whole-row-cull-vs-clip divergence the review found
(predates OP5, made user-visible by OP5's 240-260px filter blocks). Not
fixed in this round (a renderer-level scissor stack is out of scope
here) — corrected the OP5 connected-gate script instead so a straddling
block's disappear-then-reappear-whole is no longer reported as a
self-sizing regression.
- S3 (SHOULD-FIX): the chatWindowMainFilter round-trip test already
existed in e71e5a96 (the review missed it scrolling past line 330);
added the genuinely missing coverage instead — a composed test pinning
RetailUiRuntime.MountChat's window-0 SettingsStore -> ChatWindowState
seed (MountChat itself needs live DAT access and isn't unit-testable
directly).
- N11: ScrollbarLinkage_ModelPointsAtTheChatListBoxScroll now asserts
through the scoped page-slot lookup (UiElement.FindDescendant) instead
of the flat layout.FindElement, which passed for the wrong reason given
the shared scrollbar id 0x10000201 — matches OP6's own scrollbar-linkage
test pattern.
Also updated ConfigOptionsPageControllerTests' local ChatOptionsPageController
Bindings fake for the new FlushOpacity parameter.
Full Release suite: 13,117 passed / 4 skipped / 0 failed (baseline 13,107/4/0
post-OP6 — 10 tests added, zero skips added, zero failures).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
M1: the 'U4 resolved: zero range captions' claim is the SAME BN zero-fold
artifact lane A caught on AddHeader — the bytes at 0x0049E4C6 load two
string-id globals (ID_Graphics_Value_Hard/_Soft) and all six labelled
sliders decode cleanly (Stiffness Soft/Hard, Adjustment Slow/Fast, FOV
Narrow/Wide, Brightness Dark/Bright, Performance Speed/Detail, Degrade
Close/Far) — U4 closes the OPPOSITE way; OP5's Chat sliders already
ported the same mechanism. M2: the three Sound 'Disabled' toggles are
sense-INVERTED (the INI key names the string; the registered static is
effect_sounds_enabled = 1) — SFX/ambient ship muted for every user
including existing settings.json installs, undoing Campaign A; a test,
AP-199's wording, and gate step 6 all enshrine it. M3: UI_ChatFontFace
ships 1 of retail's 5 authored choices (all five font strings verbatim
in the binary) with default index 2 out of range — a self-inflicted
copy of the genuine LandscapeDrawDistance oddity. S1: the sixth
trailing separator missing (retail builds 39 items); lane A's '24 rows'
is itself stale (27 is right). S2: Screen Brightness overloads Gamma
([-1,1] default 0 into a [0.5,2.0] multiplier defaulting 1.0). S3:
tooltips reach 12/30 rows (TooltipText exists only on UiButton). S4:
the 800x600 default is absent from the hardcoded resolution list. S5:
the tests pin safe seams and miss every risky one (no ApplyAudio value
assertion, no label/choice conformance, no per-row default pin, no
count pin). A large verified-clean catalog is recorded so the rework
does not re-litigate.
Rework round 1 queues behind the OP5 fixer.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Binds the retail Options panel's Config tab (LayoutDesc 0x21000029, 27
authored rows across 6 sections) through OP2's template mechanism and
OP3's per-page OptionPage model, matching the Character/Chat tab
controllers' established pattern.
The row table is transcribed directly from two decompiled sources —
gmConfigUI::InitOptions @0x0049E400 (row order, widget shape, defaults)
and gmClient::InitUIPreferences @0x004035b0 (the complete
UIPreferences::AttachPreference registration: every label/tooltip key,
every slider's real-unit range, every menu's enum choices) — which
resolves the research docs' own "U4" unverified slider-caption pairing:
retail ships ZERO range captions on this tab (every SetSliderLabel call
passes literal string id 0).
Consumer disposition: LIVE — Sound/Ambient volume-trio sliders and their
toggle halves (AudioSettings.SfxDisabled/AmbientDisabled now gate the
already-live engine write; RuntimeSettingsController.SaveAudio newly
pushes into OpenAlAudioEngine on every change, not just at startup),
Resolution/Full Screen (immediate window resize on save). NEXT-LAUNCH
(pre-existing precedent): Sync To Refresh, Field of View. STORE-ONLY
(register rows AP-198/199/200, TS-74 extended): Sound Features/Interface
trio/Play-Only-When-Active, the nine Graphics/Rendering-Quality rows
(Vulkan has no per-feature render knobs), Camera/Input's six rows and
Use Mouse Turning (no persistent mouse-turning camera mode), Chat Font
Face/Size (distinct new fields from the existing live ChatSettings.FontSize).
AudioSettings/DisplaySettings/CameraTurningSettings/ChatSettings each
gain new fields for their slice of the 27 rows, backed by SettingsStore
round-trips. A real bug caught by testing: the scrollbar scope lookup
used the standalone-layout root id (0x100001FF), which does not survive
base-merge into the host-mounted tree — fixed to scope from the tab
host's own page-slot id (0x10000213), matching Chat's established
pattern for the same shared-scrollbar-id hazard (0x10000201, authored by
both the Chat and Config ListBoxes).
30 new tests (27 authored rows register as 30 IOptionRow instances — the
three toggle+slider trios each register two). Full Release suite:
13,107 passed / 4 skipped / 0 failed (was 13,083/4/0 — net +24, the one
existing RuntimeSettingsControllerTests case updated for SaveAudio's new
live-apply call, not a regression).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The retail port held under independent re-derivation: AddChild takes ONE
64-bit mask (@0x004862A0, the Society-row split-direction confirmation),
both spot-checked filter blocks byte-exact (12/13 rows, Gameplay
fall-through cases), the (0x16,2) enum-map order proven from
GetDIDFromEnum's body, AP-195's LED swap lands on state 6 of the exact
13x13 face child, self-sizing real, fixtures strictly additive (33
files, zero deletions).
M1: the opacity apply closures never refresh their OWN slider — drag
Default only, Reset: the value reverts (windows change) but the thumb
stays; Defaults self-heals only by both-rows luck. S1: SaveChatOpacity
does a full load+WriteAllText per drag TICK (dozens-to-hundreds of
synchronous JSON round-trips per drag; retail batches via the dirty
timer) — mark-dirty + flush on drag-end/Apply/hide. S2: the
whole-row-culling viewport makes a straddling 240/260px block VANISH
(no scissor stack), and the gate script asks the user to report exactly
that as a regression — false-failure generator + missing register row.
S3: no test for chatWindowMainFilter round-trip or the window-0 seed.
N11 (forward, OP6): 0x10000201 is authored under BOTH the Config and
Chat slots and the flat FindElement returns Chat's (last-write-wins);
OP5's linkage test passes through the flat lookup for the wrong reason.
Fix round queues behind the OP6 builder.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
R1: the timestamp prefix moves from ChatLog.Append (which stamped the
stored BODY, rendering 'Alice says, "13:05:09 hi"') to ChatVM's display
composition — FormatTimestampPrefix(entry.Received) prepends the COMPOSED
line, matching retail's separate-leading-string model (fprintf("%ls%ls",
ts, text) @0x00563e5b; AddTextToScroll receives composed lines). The
prefix renders entry.Received in LOCAL time (retail strftime), invariant
literal colons. The ten defect-pinning test cases across
ChatLogTests/RuntimeCommunicationStateTests are rewritten to pin the
corrected contract (stored bodies stay clean; the composed line carries
the stamp outside the quotes — ChatVMTests).
R2: open option-bearing panels converge on every PlayerDescription seed:
OptionPage.ReloadFromLive (per-row live re-read + gating re-eval, NO
AfterApply flush — the seed just cleared the dirty module),
OptionsPanelController.OnServerOptionsSeeded (active page),
CombatUiController.OnServerOptionsSeeded (SyncControls), wired through
RuntimeSettingsController.ServerOptionsSeeded from the same factory hook
LockUI already uses. Retail cannot reach this state (its panels close
across login); the adaptation exists because retained panels survive the
session boundary — documented at the seam.
R3: tests drive the refresh widget push (model AND checkbox converge) and
ReloadFromLive's no-flush contract. R4: AP-196 addendum names the
headless AutoRepeatAttack false->true effective-default flip and the
characterOptions escape hatch.
Full Release suite: 13,083 passed / 4 skipped / 0 failed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Binds LayoutDesc 0x2100005C through OP2's template-list mechanism and
OP3's per-page OptionPage model: the General Options header + two
DualHash-linked opacity sliders (Option_DefaultOpacity_Property
0x10000080 / Option_ActiveOpacity_Property 0x10000081, live-apply on
drag through RetailWindowOpacityController, defaults read from the
installed DAT's DBProperties collection at DID 0x78000001 via
ChatOptionsDatDefaults), and the five per-window text-filter blocks
(main window 12 rows minus Gameplay, four floaties 13 rows each — the
byte-verified authored order cross-checked against the raw
gmChatOptionsUI::InitOptions/AddCheckboxBitfield64Option pseudo-C, not
just the research doc's own table) writing AcDream.Core.Chat.
ChatWindowState directly, the same state CH6's chat windows already
read.
AP-195 retired: ported both halves left open at the OP2 re-review —
the ALL-set LED media swap (new UiButton.FaceFileOverride, driven by
the block-level P0x10000082/P0x10000083 sprites now threaded through
ElementInfo/DatWidgetFactory) and the CreateChildren self-sizing tail
(UiCheckboxBitfield64.Height grows with its stacked row content; the
enclosing ListBox reflows around the block's FINAL height via the new
UiTemplateListBox.AddPrebuiltRow, reusing the ListBox's own stacking
rather than a third stacking path). AP-187 broadened to cover the main
window's own filter (previously only the four floaties) and the new
live-editing write path.
The main chat window's filter (retail window id 8, ChatWindowState id
0) gains its own settings.json persistence (ChatSettings.
ChatWindowMainFilter) alongside the pre-existing floaty 1-4 fields;
opacity persistence is now wired on every live slider change, not only
through the old dev-scaffold Settings panel.
Fixture regeneration (ACDREAM_REGENERATE_UI_FIXTURES=1) picked up the
new ElementInfo.LedCheckedSprite/LedUncheckedSprite fields across all
19 committed layout fixtures — purely additive, confirmed against the
live installed DAT (0x10000520's own 0x82/0x83 properties resolve to
0x06004D17/0x06004D19 exactly as AP-195 documented).
Conformance: FilterRows/FilterBlocks pinned against the byte-verified
authored order and ChatWindowState's own default constants; the AP-195
LED swap and self-sizing behavior; the DAT opacity-default extraction
against the live installed DAT; live filter/opacity writes reaching
ChatWindowState/RetailWindowOpacityController; OnShown re-seed and
Reset/Defaults ghosting per the OP4 binding-pattern discipline.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
MF-1/M1 CLOSED with the blast scenario walked (Reset now restores live
values; UiLocked converges on every seed). MF-2 CLOSED with a
load-bearing fixture check (all nine verb buttons author Ghosted and
lack DAT 0x0B — TrySetRetailState would otherwise short-circuit). M2
store retirement verified to zero remaining references; M3 walked; S2's
.spv hygiene verified by recomputing the compiler hash scheme.
R1 MUST-FIX: the timestamp now prefixes ChatEntry.Text (the BODY), so
six of ten chat kinds render 'Alice says, "13:05:09 hi"' — retail
composes the line FIRST and prepends the timestamp as a separate string
at display time (AddTextToScroll @0x00563c50 receives composed lines;
fprintf("%ls%ls" ts, text) @0x00563e5b). Fix at ChatVM's display
composition; the two new tests pin the defect and must be rewritten.
R2: the seed-event re-read for OPEN panels (Combat vs Character can
disagree until re-shown; a panel open across reconnect reopens the
wrong-direction Reset for that window) — wire the existing
OnCharacterOptionsChanged hook like LockUI or register it. R3: no test
drives the refresh widget push. R4: headless AutoRepeatAttack default
flip false->true unnamed in AP-196.
Coordinator fixes directly (second round) once OP5 frees the tree.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Both OP4 reviews converged on one headline bug (Character-tab rows never
re-read live server truth after their pre-login constructor-word seed) plus
overlapping MUST-FIXes. All ten converged/consolidated findings land here:
MUST-FIX:
- BoolOptionRow.SaveCurrentValue now re-reads its live binding (retail's
GetValue()-into-SaveCurrentValue) on every OnShown — panel open, tab
switch in, initial activation — instead of trusting the pre-login
constructor word it was built with. Reset/tab-switch can now only
restore values that were actually live at the last show. LockUI's
host.Root.UiLocked one-shot mount seed now also converges on every
PlayerDescription via the existing OnCharacterOptionsChanged hook.
- Apply/Reset are wired to OptionPage.OnOptionChanged in production
(Ghosted when nothing changed, Normal when dirty, run once at bind so
both start disabled per retail's PostInit); Defaults stays ungated.
- The Combat panel's three LEDs (Repeat Attacks/Auto Target/Keep in View)
now read/write the same RuntimeCharacterOptionsState seam the Character
tab uses instead of a disconnected client-local GameplaySettings copy —
closes the "two writable copies" divergence. The three now-orphaned
GameplaySettings fields and RuntimeSettingsController's mirror
properties/SetCombatGameplay are deleted outright; the headless host's
hardcoded AutoRepeatAttack/AutoTarget now read the live option bit.
- RuntimeSettingsController.SetUiLocked's convergence guard now compares
against the last value actually applied to the runtime target instead
of the persisted GameplaySettings.LockUI snapshot, which could already
match a server-derived request without ever having been pushed.
SHOULD-FIX:
- DisplayTimeStamps now prefixes every chat producer (ChatLog.Append is
the one seam all of them funnel through), not just AddText's own
callers — heard speech, emotes, Turbine channels, and combat text were
previously missed. The prefix format escapes its colons and forces
InvariantCulture instead of the culture-dependent TimeSeparator
placeholder.
- sky.frag now honors uFogParams.w (fog mode) like the mesh/terrain
shaders, so Disable Distance Fog stops the sky dome's horizon band from
blending toward fog color too.
- Corrected the "byte-verified" overclaim on the timestamp format string
doc comment (BN-sourced, wire doc U6) and the AP-194 anchor-column
class-name typo; the RunAsDefaultMovement doc comments now cite retail's
actual acclient.h enumerator name.
- Added: DispatcherMovementInputSource's option x modifier truth table
(incl. || AutoRunActive with the option off), the per-page Apply/Reset
enable-gate tests, a real checkbox.OnClick/ToggleBehavior-driven click
test, and hash-pins for the six header string keys.
- Gate script step 8 corrected for the logout-flush false-failure
(closing the panel before relogging is load-bearing); a new step
documents the enable-gate sequence and the Combat-panel/Character-tab
cross-check.
Register: AP-196 (the Group-C default-source change + GameplaySettings
retirement) and AP-197 (the ignored per-character timestamp format
override) filed in this commit.
Full Release suite: 13,044 passed / 4 skipped / 0 failed (was 13,008/4/0;
net +36 tests from new coverage and legitimate assertion updates from the
GameplaySettings retirement).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
MF-1 converges with blast M1: rows seed once at startup pre-login and
never re-read; retail's own schedule is the fix (UIOption_Checkbox::
SaveCurrentValue @0x004868E0 = m_current = GetValue() live, m_saved =
m_current, on every OnShown — predicted by OP3 review note N2). MF-2:
the OnOptionChanged Apply/Reset ghosting seam (Ghosted 0x0D / Normal,
PostInit runs it once so both start disabled) is wired by NOTHING in
production. The Defaults correction is independently VERIFIED from the
decomp (SetPlayerOption @0x00486e80 sets m_default from
GetDefaultOptionValue; InqDefaultGameplayOptionProperty's only caller is
the Chat tab's slider path — the plan's U1 directive would have made
Defaults restore nothing). Cross-page button collision confirmed from
the fixture (0x100001FC/FD/FE x3). 18 rows traced end-to-end; six header
hashes + 49 label globals hand-verified. SHOULD: culture-dependent
timestamp separator + overclaimed byte-verification; no
OnClick/ToggleBehavior-driven LED test; gate item 8's logout-flush false
failure. NOTEs incl. sky-dome fog and no mid-session echo repaint.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
M1: the Character tab binds at startup, pre-session — rows seed from
constructor words and nothing re-seeds on PlayerDescription; Reset (and
tab-switch, which is OnHidden->Reset) then publishes the STALE baseline
over server truth, and the one-shot UiLocked assignment can permanently
disagree with the radar's polled read. M2: the Combat panel's three LEDs
still write/read the orphaned GameplaySettings copy — decorative,
wire-less, and visibly divergent from the Character tab's rows for the
same PlayerOptions (the CH3 two-writable-copies mode); headless
hardcodes for the same ids filed alongside. M3: ToggleUiLock's
convergence guard (initialized converged, now comparing values from two
different stores) can early-return past ApplyUiLock — one press flips
the wire and radar but not the windows.
S1: AddText is NOT acdream's transcript chokepoint — speech/tells,
emotes, Turbine channels, combat text and @-replies write ChatLog
directly, so the timestamp prefix reaches only ServerMessage/WeenieError
lines and gate step 13's tell/say half cannot pass. S2: sky.vert never
reads the fog-disable param — crisp terrain against a fogged horizon
with the option on. S3/S4: missing register rows for the seven
default-source changes (ViewCombatTarget's observable default flips) and
the orphaned-but-written GameplaySettings; the timestamp format is
culture-dependent where retail strftime is literal. S6: the two changed
consumer files ship without their test files touched.
Clean: scoped per-page searches structurally sound (fixture-verified),
GetOptionBit thread-safe/allocation-free, the run-XOR byte-correct vs
SetHoldRun @0x006b3370 with wire encoding untouched.
Gate steps 3 and 13 cannot pass pre-fix — the OP4 gate must wait for
the fix round.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Binds LayoutDesc 0x21000028 (gmCharacterSettingsUI) through OP2's
template-list mechanism and OP3's OptionPage model: 6 authored group
headers + 50 toggle rows (49 from the 2013 build + D3's "Listen to PK
death messages", AP-193) in research doc §2's authored order, each row
resolved by PlayerOption id through CharacterOptionTable, seeded from
live RuntimeCharacterOptionsState, defaulted from CharacterOptionTable.
ClientDefault (byte-verified against UIOption_Checkbox::SetPlayerOption
@0x00486e80's own GetDefaultOptionValue call — AP-194 updated to confirm
the directive was followed), labels/tooltips resolved by name from
string table 0x23000003 (never hard-coded English), and registered with
OptionsPanelController.CharacterPage. Apply/Reset/Defaults
(0x100001FC/FD/FE) are now wired per-page via a scoped subtree search
(UiElement.FindDescendant, promoted from UiTabPanel) since Character/
Chat/Config each author their own physical instance under the SAME
element ids.
Consumers: Group A (29 ids) wire+store only via the existing
SetSingleCharacterOptionRuntimeCmd/TrySetOption seam. Group B: Display
Timestamps prefixes new transcript lines (RuntimeCommunicationState.
DisplayTimestampsSource); Disable Distance Fog forces FogMode.Off
(WeatherSystem.DisableDistanceFogSource, retiring half of TS-73); Run as
Default Movement inverts the walk-mode modifier's default
(RuntimeLocalPlayerMovementState.RunAsDefaultMovementSource). Group C
re-points AutoTarget/AutoRepeatAttack/ViewCombatTarget
(CharacterOptionCombatSettingsSource), VividTargetingIndicator/
CoordinatesOnRadar/LockUI/AcceptLootPermits from the client-local
GameplaySettings record to the canonical server bit — closing two
previously-unfiled divergences where AutoRepeatAttack and
AcceptCorpseLootingPermissions never reached the wire despite being
retail auto-save ids. TS-73 narrowed to its two still-open cases;
TS-75..TS-80 file the genuine gaps (no day/night force, no weather-
particle/profanity-filter/salvage/housing/pickup-preference subsystem,
fellowship-create's unaudited client-sourced field) rather than
inventing stand-ins.
Conformance: CharacterOptionsPageControllerTests pins all 50 rows
against CharacterOptionTable in both directions (an invented or dropped
row fails the build), the authored group/order row-by-row, and the
build/seed/Apply/Reset/Defaults/wire-publish behavior end-to-end against
the committed fixture. 52 new tests; full solution suite 13,008 passed /
4 skipped / 0 failed (was 12,956/4/0).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Evidence appended to the test script: run-1 diff-and-send exact to
contract; mid-run reconnect idempotence; run-3 cross-process persistence
proof (the fresh seed echoed the blob-only SalvageMultiple value);
graceful converged exits; no refusals, no pre-LoginComplete sends.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
R1: the gate script no longer promises a timestamp prefix on the Magic
macro lines — acdream renders no chat timestamps yet (the Display
Timestamps consumer is OP4 scope; no chat-log file exists, TS-69). A
bare light-blue transcript line is the CORRECT gate outcome.
R2: IsGrounded yields null (silent) for a NULL controller in player
mode — the prior pattern returned false and fired the mid-air refusal
retail cannot produce in that state; comments now match the code.
R3: the dormant-ActivePageChanged pin now applies the real stimulus —
every authored tab button on a dormant host must carry NO click handler
(RetailTabBinding.SetClick never ran), which is AD-73's actual dormancy
mechanism; SwitchTo deliberately has no guard.
OP3 is CLOSED: dual APPROVE-WITH-FIXES -> fix round 386076af ->
re-review REOPEN(narrow) -> this pass. Connected gate now READY.
Full Release suite: 12,956 passed / 4 skipped / 0 failed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
TWO work products share this commit (a staged-index collision between the
coordinator's docs commit and the OP7 fixer's staged files — content
verified complete and coherent; only this message was wrong before the
amend):
1. OP7 review fixes (all nine findings from
docs/research/2026-08-11-op7-review.md):
- M1: HeadlessSessionDescriptor is a record; WithAccount uses 'with' non-destructive record copy,
so a future property cannot be silently dropped; direct-CLI
regression test proves CharacterOptions survives --user/--password.
- M2 root fix: LiveSessionEventRouter skips BOTH Replace and the
options notification on a trailer-truncated PlayerDescription — a
truncated re-seed can no longer install zeroed words under an armed
latch for OP7's automation to flush into 0x01A1.
- SF1: schema keys validate as ordinal strings against the allowed
names (numeric / comma-combined aliases rejected). SF2: both-true
fellowship exclusion rejected at load, naming both keys. SF3: the
onLoginCompleteSent observer moved after transit.EndTeleport().
SF4: production-hook coverage for all three LoginComplete sites.
SF5: test-script OP7 wire expectation corrected (batched ids ride
only the 0x01A1).
2. docs/research/2026-08-11-op3-rereview.md — OP3 re-review verdict
REOPEN (narrow): M1 byte-decode independently re-verified (6a 07 at
all six sites); residuals R1 (gate script promises a timestamp prefix
acdream doesn't render), R2 (null-controller player-mode still
refuses), R3 (dormancy pin lacks stimulus) — coordinator fixes follow.
Full Release suite at this tree: 12,956 passed / 4 skipped / 0 failed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Consolidated fix round for the two OP3 dual-lens reviews
(docs/research/2026-08-11-op3-review-{mechanism,blast}.md), both
APPROVE-WITH-FIXES.
MUST-FIX:
- The six "Use Mouse Turning Settings" chat lines were typed
RetailLogTextType.ClientLocal (0x1A); retail types them 0x07 (Magic).
BYTE-VERIFIED against the PDB-paired binary at all six
gmConfigUI::SetMouseTurningDefaults call sites (0x0049E972/E9E2/EA52/
EAA4/EAF6/EB48): every site pushes `6a 07` (type=7) immediately before
the text-pointer push and the AddTextToScroll call. Added a dedicated
OptionsRuntimeBindings.DisplayMouseTurningMacroLine seam routed at
Magic (scrolling chat transcript, light blue, timestamped) instead of
the 4-slot SpewBox ClientLocal uses; the mid-air refusal and UA/RA
keep ClientLocal (both independently confirmed correct).
- Filed AD-77: the client-wide floating-only gmPanelUI host divergence
(retail also exposes a docked 0x21000017 host) the plan §5 delegated
to this review, scoped to every main panel, not just Options.
SHOULD-FIX:
- gmGameplayOptionsUI is not an OptionPage in retail (acclient.h:55857,
UIElement_Field). OptionsPanelController now constructs the Gameplay
slot's OptionPage with AfterApply deliberately null, so entering/
leaving that tab never publishes SaveCharacterOptionsRuntimeCmd.
Corrected OptionPageModel's doc comment and rewrote the two tests
that pinned the wrong (Gameplay-flushes) shape.
- Added the OptionPage.OnOptionChanged seam (PlayerOptionPage::
OnOptionChanged @0x004F27D0) — fires as the last step of Apply/
Reset/Defaults, plus once per live LED edit via a new
IOptionRow.AttachPageNotify hook (BoolOptionRow wires it into
SetCurrentValue only, matching retail's Apply(1)-only
HandleDialogAndNotices path). OP4-6 will bind Apply/Reset enable
state to this.
- Exit to Character Selection's mid-air refusal is now tri-state
(Func<bool?> IsGrounded): retail's UseTime only reaches the airborne
test inside `else if (smartbox->player)`, so outside player mode (or
with no live controller) the button is a SILENT no-op, not a
refusal. Fixed the inverted comment at both call sites.
- Options panel geometry now matches its nine gmPanelUI siblings
sharing RetailPanelUiController's one main-panel rectangle
(ResizeX=false, bottom-edge-only resize, no invented Min/MaxWidth/
Height) instead of being the only all-four-edge/horizontal-resize
outlier whose width silently reverted whenever a sibling was shown.
- Added the three missing test pins: Options/Character mutual
exclusion through a REAL RetailPanelUiController registration,
RetailDialogFactory.MakeConfirmation's omitted-queueKey overload
sharing DefaultQueueKey, and UiTabPanel.ActivePageChanged never
firing on a dormant (non-activated) host.
- TS-74's What/Where now names the five store-only CameraTurning
preferences explicitly instead of only mentioning them in Risk.
- Test script gains the toolbar-button ghosted->enabled+highlight
check, UseMouseTurning-survives-relogin and the five prefs-survive-
relaunch steps, a UA/RA legibility eye-item, and the corrected
bottom-edge-only geometry description for step 5.
One-liners fixed in files already touched: symmetric close-button
resolve-failure logging in OptionsPanelController.Bind (blast NOTE 8).
Full Release suite: 12,947 passed / 4 skipped / 0 failed (baseline
12,935/4/0 post-OP7 — 12 net new tests; the two OptionPageModelTests
"wrong-shape" tests were renamed/rewritten in place, not removed).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The 26-name allow-list cross-checks clean against research §5.2,
CharacterOptionTable, and retail's PlayerOption enum — all three risky
renames verified right (ToggleRun IS PlayerOption 0x0A run-as-default,
not the keybind latch). #368 honored; existing configs byte-identical;
per-route seeder leak-free; the Runtime observation hook never fires in
the graphical host.
MUST-FIX 1: HeadlessProcessHost.WithAccount hand-copies six of seven
descriptor fields — CharacterOptions is dropped, so the K3 direct-CLI
launch mode silently no-ops the whole feature. MUST-FIX 2: a truncated
PlayerDescription RE-seed re-opens OP1's wipe class — Replace installs
zeroed words while the latch stays armed from the earlier complete seed,
and OP7's automation then flushes those zeros into 0x01A1; fix at the
seam (a truncated parse installs nothing, notifies no one). SHOULD:
validate schema names as strings (Enum.TryParse accepts numeric and
comma-combined keys that alias into the allow-list); reject the
contradictory fellowship pair at load (declared both-true oscillates
against retail's mutual exclusion); move the mid-teleport hook to the
sequence tail; cover the three production LoginComplete hooks with the
real controller argument; correct the OP7 test-script line expecting a
0x0005 for batched SalvageMultiple (only the 0x01A1 carries it).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds an optional, strict `characterOptions` block to the headless bot config
(D8): keys are exactly the lane-B tier-1 (22) + tier-2 (4) bot-declarable
CharacterOptionId enum-member spellings; an unknown/out-of-tier name fails
config load naming the offending key, before it can ever reach the wire.
HeadlessCharacterOptionsSeeder diffs declared-vs-actual once both of ACE's
real preconditions are known true — GameActionLoginComplete sent (the
FirstEnterWorldDone gate SetCharacterOptions 0x01A1 needs) and a real
PlayerDescription has seeded RuntimeCharacterOptionsState
(HasServerSeed) — learned from whichever of two hooks lands second. Every
differing id routes through OP1's shared IRuntimeCharacterCommands seam:
auto-save ids send SetSingleOption (0x0005) immediately; batched ids also
call SetSingleOption (which only dirties the module) followed by exactly
one SaveOptions flush after the whole declared set has been walked.
Idempotent on reconnect by construction — no dedupe latch, the diff simply
finds nothing once the server agrees.
RuntimeLiveEntitySessionController gains a passive onLoginCompleteSent
observation hook (additive only, never changes when/whether it sends) so
the headless host can learn ACE's gate opened from any of its own two
internal send sites; the third site (direct first-entry completion) is
already owned by HeadlessSessionHost itself. All wiring is synchronous
delegate calls on Runtime's one dedicated update thread — no new
async/Task continuation, honoring #368.
Tests: schema (valid parse, unknown/tier-3 name rejected naming the key,
non-bool rejected, empty/absent no-op), the diff engine against a fake
IRuntimeCharacterCommands (nothing-to-send, auto-save-only, batched-with-
flush, mixed ordering, reconnect idempotence), and two wiring integration
tests — one dispatching a real PlayerDescription game event end-to-end to
a captured wire action, one proving the send lands on the same dedicated
thread every Tick runs on. Full solution suite: 12,935 passed / 4 skipped
/ 0 failed (+17 over baseline 12,918/4/0).
No register row: the characterOptions bot-config surface is acdream-
native tooling over retail's own wire mechanisms (both already ported by
OP1), not a retail UI port with a divergence to record.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Converged MUST-FIX: the six mouse-turning macro lines are typed
ClientLocal 0x1A where retail's AddTextToScroll sites pass type 7 (Magic,
light blue — mechanism lens decoded the stray [4]=7 writes at all six BN
call sites); 0x1A additionally routes to the SpewBox where the 4-slot cap
discards two of the six lines before they draw (blast lens). Fix: a typed
message seam, Magic for the macro, ClientLocal retained for the mid-air
refusal and AD-75; byte-verify the type argument during the fix.
Mechanism: the Gameplay page is NOT an OptionPage in retail
(gmGameplayOptionsUI : UIElement_Field, acclient.h:55857) — the
auto-flush-on-visibility acdream gave it flushes the blob at moments
retail would not, and two green tests pin the wrong shape; the
OnOptionChanged enable-gating seam (@0x004F27D0 — Apply/Reset gated,
Defaults never) is missing from the model; the mid-air refusal fires
where retail is silent (non-player-mode/null controller).
Blast: the plan-assigned docked-host (0x21000017) register ruling was
not filed — deemed a divergence, row owed client-wide; Options is the
only shared-geometry panel with ResizeX/four-edge/min-size, which the
shared _mainPanelGeometry silently reverts when siblings show; three
targeted seams lack pins (real-sibling mutual exclusion, DefaultQueueKey,
dormant ActivePageChanged silence); UA/RA legibility flagged for the
gate. Clean: exactly one toolbar button changed (previously ghosted),
F11 same-action since K.1c, no queue key invented, settings additive.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Mounts retail's Options panel (LayoutDesc 0x2100002B resolved through host
0x2100006E slot 0x1000018D, gmPanelUI key 10) via the same catalog-import
pattern CharacterController already validates, registered through
RetailPanelUiController so it shares retail's "one active gmPanelUI child"
mutual exclusion with every other sibling panel for free. F11 and the
toolbar's options button (0x1000019B, already authoring panel id 10) both
now open it; the close button fires the same ToggleOptionsPanel action.
OptionPageModel (OptionPage/BoolOptionRow) ports retail's exact
Apply/Reset/Defaults/visibility semantics from
UIOption_Checkbox/PlayerOptionPage — LED clicks apply live immediately,
Apply commits every row unconditionally + flushes the batched blob, Reset
reverts only Changed rows, Defaults restores without committing, and
tab-switch/window-hide revert uncommitted edits. Wired for all four tabs;
this slice registers real rows on none of them (Gameplay authentically has
none — a pure button list). UiTabPanel gains an ActivePageChanged event so
the page model can hook every tab transition, including the initial
default-tab activation.
The seven Gameplay-tab buttons: Exit Game reuses the existing graceful
window-close path; Exit to Character Selection gets retail's confirmation
dialog and byte-verified mid-air refusal but still behaves as Exit Game
(AD-74 — no pre-world character-select flow exists); Configure Keyboard
and In-Game Help Files are inert this slice (AD-76 for Help — the
plugin retail depends on doesn't exist); Urgent Assistance/Report Abuse
short-circuit to their own byte-verified failure text through the
interface-text seam instead of ShellExecute against a dead URL (AD-75);
Use Mouse Turning Settings runs the pure MouseTurningSettingsMacro port,
persisting five new CameraTurningSettings preferences and sending
PlayerOption.UseMouseTurning — TS-74 records that acdream has no
persistent mouse-turning camera mode for the bit to drive yet.
Full Release suite: 12,918 passed / 4 skipped / 0 failed (baseline
12,871/4/0 — only new tests added).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Closes the mechanism lens's REOPEN (one MUST-FIX) and both lenses' small
residuals on the OP2 rework (b236a442); the blast lens re-review was
CLOSED outright. Fable-direct per the two-failure escalation rule.
- AP-195 filed: UIOption_CheckboxBitfield64 ports HALF of Refresh
@0x004859C0 — the ANY-set checkbox predicate is exact, but the ALL-set
LED media swap (P0x10000082=0x06004D17 / P0x10000083=0x06004D19) and
the ListBox self-sizing tail (ResizeTo/CalculatePaperSize — the block
IS a UIElement_ListBox in retail) are unported, and the block's row
stacking is a second divergent implementation beside UiTemplateListBox.
All due at OP5 before the Chat tab's connected gate; the IsSet doc
comment now names both halves instead of quoting only the ported one.
- Row tooltips: UiButton gains settable TooltipText surfaced through the
shared GetTooltipText hover pipeline (UiCatalogSlot's pattern);
UiCheckboxBitfield64.AddChild applies the row tooltip retail stamps in
CreateChildren @0x00485DF0, and documents that the 0x10000084 row-index
attribute stamp is deliberately replaced by the typed mask closure.
- AD-73 addendum: UiTemplateListBox.ConsumesDatChildren=true is inert
only while no authored Type-5 element carries children — that premise
is now conformance-PINNED across all 32 fixtures (a future DAT
regeneration surfacing an authored child fails the build instead of
silently dropping it).
- Plan doc: OP2's contract names UiTabPanel.cs (retail UIElement_Panel),
not the fictional-class-named UiTabControl.cs; ledger records OP1 and
OP2 both CLOSED.
Full Release suite: 12,871 passed / 4 skipped / 0 failed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
R1: the K4-load-bearing IsDirty pre-check moves into GameRuntime's two
once-allocated hook lambdas — SendBlob's closure environment is allocated
in FlushCharacterOptions's PROLOGUE, ahead of any guard inside the body,
so the clean-tick fast path must never enter the method at all. The body
keeps its check as idempotent defense only; the doc comment now describes
the real mechanism instead of overclaiming.
R2: RuntimeCharacterOptionsState gains a dirty-generation token. MarkDirty
bumps it on EVERY call (including while already dirty); TryFlush /
TryFlushIfAutoSaveDue capture it before invoking the callback and only
clear IsDirty when it is unchanged after — a dirtying change landing
DURING a flush (cross-thread, or re-entrant from the callback itself,
the re-review's NOTE-6 case) now stays dirty and flushes on its own later
trigger instead of being silently erased by the trailing clear. The S2
interleaving test now asserts the retained dirty state it previously
ignored; a deterministic re-entrancy test pins the same-thread shape.
R3: a trailer-truncated PlayerDescription parse carries zero placeholder
option words, not server truth — GameEventWiring now forwards
TrailerTruncated, LiveSessionEventRouter passes armServerSeed:
!trailerTruncated, and Replace withholds the 0x01A1 flush authorization
for truncated seeds while still installing the words (pre-existing local
behavior unchanged). Newly wire-reaching via the R1/MF-1 timer, hence
closed now rather than left a NOTE.
Full Release suite: 12,870 passed / 4 skipped / 0 failed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
OP2 (df9c7a35) was double-REJECTed: an unconditional Type-8/Type-5 factory
mapping silently re-classed 15 elements across 7 shipped panels (vendor
backdrop lost its fill, character/spellbook roots stopped passing clicks
through, combat gained a phantom import-time tab takeover, ten ListBoxes
gained a spurious hit-testable viewport) because the stale 27 pre-existing
fixtures never exercised the new fields — and the mechanism itself cited a
nonexistent "UIElement_TabControl" class, inverted UiCheckboxBitfield64's
checked-state predicate, and synthesized fake per-row geometry instead of
using the widget's own authored template.
Shape change: UiTabPanel (renamed from UiTabControl) and UiTemplateListBox
now derive from UiDatElement (unsealed) and stay DORMANT by default — an
imported Type-8/Type-5 element gets authored-media drawing, ClickThrough
generic-decoration default, and IUiDatStateful propagation identical to the
pre-OP2 UiDatElement fallback, with zero import-time side effects. The
factory's Type-8/Type-5 arms are unconditional again (no more guard whose
premise the blast-radius sweep proved false), because dormancy makes an
unactivated instance behaviorally indistinguishable from the old fallback.
UiTabPanel.ActivateTabBehavior() and UiTemplateListBox's lazy viewport
creation are the explicit, controller-driven opt-ins Campaign OP slice OP3+
will call; today nothing does, so the four pre-existing shipped Type-8
hosts (character/spellbook/vendor/combat) and ten pre-existing Type-5
ListBoxes keep their pre-OP2 behavior exactly. Filed AD-73 for this
dormant-vs-retail's-unconditional-activation adaptation.
Mechanism fixes (docs/research/2026-08-11-op2-review-mechanism.md):
- UiTabPanel cites UIElement_Panel (Type 8 is UIElement_Panel; no
UIElement_TabControl exists in the PDB), resolves buttons/pages via a
GetChildRecursive-equivalent descendant search (not direct-children-only),
performs no switch when no entry authors 0x32 (deleted the _tabs[0]
fallback), and surfaces unresolved tab-table entries via UnresolvedEntries
+ a diagnostic line instead of a silent no-op.
- ElementReader.ReadTabTable skips entries missing 0x30/0x31, matching
retail's SetupTabPageHash @0x0046C2E0 entry filter.
- UiCheckboxBitfield64 now builds every row from its OWN authored template
(property 0x64 -> {0x2100002B, 0x10000521}) via AddItemFromTemplateList,
deleting the synthesized ElementInfo + invented RowHeight=14 — matching
retail's CreateChildren @0x00485DF0, which is itself a UIElement_ListBox
call. IsSet is now retail's ANY-bit-set predicate (Refresh @0x004859C0),
not all-bits-set. TS-72 retired: the click-toggle bit math is now fully
decomp-confirmed (SetBitsOnOrOff via ListenToElementMessage @0x00485AE0).
Regenerated all 32 UI fixtures against real DAT (ACDREAM_REGENERATE_UI_FIXTURES=1)
and committed them — 27 pre-existing fixtures now carry Outline/OutlineColor/
TabTable/TemplateList/ScrollbarElementId; the 5 Options fixtures were already
current. Updated EffectsUiControllerTests' now-correct UiTemplateListBox
class-identity assertion. Added: 6 built-widget behavior pins for all five
pre-existing Type-8 elements + a representative Type-5 element the dormancy
model protects (OP2ReworkBlastRadiusConformanceTests.cs); 5 reader-level
tests driving ReadTabTable/ReadTemplateList/the 0x72 reader from raw
property bags (ElementReaderTests.cs); a multi-bit-mask UiCheckboxBitfield64
test proving the any-bit predicate (the prior single-bit test couldn't
distinguish it from all-bits); an activation-idempotency test and a
before-activation click-is-inert test for UiTabPanel.
Full Release suite: 12,868 passed / 4 skipped / 0 failed (baseline 12,853/4/0
post-OP1-fixes; +15 net new tests, zero regressions).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Focused re-review verifies all ten fix dispositions genuinely close their
findings (fellowship order re-derived from OnChanged @0x0059A8E0; the
seed latch confirmed upstream of every one of the three blob call sites;
TS-71 retirement and TS-73 accuracy checked). Residuals owed to a small
coordinator pass, none reopening: R1 closure hoisting defeats the
per-tick allocation guard (move the IsDirty check into the ctor
lambdas); R2 concurrent MarkDirty during a flush is silently erased by
the trailing clear (needs the dirty-generation token); R3 NOTE — a
truncated-trailer PlayerDescription (options words zero) arms the seed
latch, newly wire-reaching via the timer.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Closes the two mechanism-lens and blast-lens dual reviews of Campaign OP
slice OP1 (86c0a7e0): docs/research/2026-08-10-op1-review-mechanism.md and
docs/research/2026-08-10-op1-review-blast.md.
MUST-FIX M1 (blast): RuntimeCharacterOptionsState gains a HasServerSeed
latch, set by Replace (the PlayerDescription seed) and cleared by
ResetSession. TryFlush/TryFlushIfAutoSaveDue now refuse before the seed
arrives — closing the window where a bot (or, after this commit, the
timer/logout triggers) could flush client-default option words over a
character's real server-side options before any PlayerDescription ever
landed.
MUST-FIX 1 (mechanism): the 480 s auto-save timer and the pre-logoff
flush are now wired into production, closing TS-71 (retired). Both ride
LiveSessionController's own tick/stop transaction via two new hooks
(ConfigureAutoSaveTick/ConfigurePreLogoffFlush), wired once by
GameRuntime's constructor — a Runtime-internal change requiring zero
host edits, exactly as the review identified. The flush body talks to
WorldSession directly rather than through App's LiveSessionCommandRouter,
which is what keeps this off the S2 lock-order hazard (below). Filed
TS-73 for the two OnChanged side-effect cases (weather/day/combat-
target/fog) TrySetOption still doesn't model — pre-anchored to OP4's
Group B consumer binds.
SHOULD-FIX S2 (blast, prerequisite for MUST-FIX 1): TryFlush/
TryFlushIfAutoSaveDue no longer invoke the flush callback while holding
_dirtyGate — the decision is made and cleared under the lock, but the
callback itself runs outside it, closing the lock-inversion hazard the
natural timer wiring would have hit (Runtime tick's _dirtyGate-then-
_gate vs the router's _gate-then-_dirtyGate).
SHOULD-FIX MF-2 (mechanism): TrySetOption now ports the two
PlayerModule-state-mutating cases of CPlayerModule::OnChanged's local
side-effect switch — turning ON IgnoreFellowshipRequests or
FellowshipAutoAcceptRequests clears the other through a real recursive
TrySetOption call, reproducing retail's second 0x0005 (the clear's send
reaches the wire before the primary option's own send, matching the
nested-call order in the decomp). The signature widened from
Action sendAutoSave to Action<uint,bool> so the recursion can send a
different (id, value) than the caller's own; every production call site
now passes WorldSession.SendSetSingleCharacterOption directly.
SHOULD-FIX MF-3 (mechanism): a hand-transcribed 53-row (id, isOptions1,
mask) theory in CharacterOptionTableTests, independently re-derived from
acclient.h's PlayerOption/CharacterOption/CharacterOptions2 enums rather
than copied from CharacterOptionTable.cs — closes the one column with no
id-by-id pin. Also added the pairwise-distinctness check blast NOTE N7
named.
SHOULD-FIX S1 (blast): LiveSessionCommandRouterTests' CH3/CH4 regression
test now drives the REAL TrySetOption binding instead of a hand-rolled
SetOptionBit substitute that had silently drifted from production after
OP1.
SHOULD-FIX S3 (blast): RuntimeCharacterOwnershipSnapshot gains
OptionsAreClean (!Options.IsDirty), included in IsConverged — a module
whose two words happen to cycle back to their default bit pattern while
still dirty is now caught by the combined ownership ledger, not just by
OptionsAreDefaults.
SHOULD-FIX S4 (blast): SaveOptions no longer encodes "did it actually
flush" as PrimaryObjectId 1u/0u (which read as object guid 0x00000001 in
the K2 event stream). Both host adapters now report the identical shape
(Accepted, objectId 0) — the graphical host never could report this
anyway (LiveCommandBus.Publish has no return channel).
SHOULD-FIX S5 (blast): Replace (the server-seed arrival) now also clears
IsDirty/FirstDirtiedAt — a wholesale re-seed supersedes any pending
batched-but-unflushed local intent (retail's own PlayerModule has no
partial-merge path either), documented at the member.
SHOULD-FIX S6 (blast): a cross-check theory asserting CharacterOptionTable's
masks equal PlayerDescriptionParser.CharacterOptions1/2's independently
(the write path vs the read path TurbineChatMembershipGate/
RuntimeSettingsController consume) — guards the exact CH3 failure class.
Also fixed a real allocation regression found while landing MUST-FIX 1:
the naive per-tick flush closure would have allocated on EVERY
LiveSessionController.Tick() call regardless of dirty state, which broke
the K4 headless 30-session resource-envelope gate. GameRuntime.
FlushCharacterOptions now pre-checks Options.IsDirty (itself retail-
faithful — CPlayerModule::UseTime opens with the identical m_bDirty byte
compare) before allocating the flush closure, so the allocation only
happens on the rare tick that might actually flush.
Dispositions on findings not changed this round:
- Mechanism NOTE 6 / not independently re-flagged: a re-entrant MarkDirty
from inside a flush callback can still be erased by the trailing
"_isDirty = false" — pre-existing, unchanged by the S2 lock restructure
(same outcome whether the callback runs inside or outside the lock),
not reachable from any current caller, not a one-liner to close
correctly (needs a per-dirty-period generation token). Left as documented
in the review; worth closing before the Options panel ever flushes from
inside a change handler.
- Mechanism NOTE 9, blast N2/N3/N4/N5/N6/N8: informational or require
touching files this round doesn't otherwise edit (SocialActions.cs,
CharacterOptionsBlobSource.cs, GameRuntimeContractTests.cs) — left per
the "one-liner in a file already being edited" instruction.
Register: TS-71 retired (both remaining SetCharacterOptions flush
triggers now production-wired); TS-73 filed (the two unmodeled OnChanged
presentation-binding cases, pre-anchored to OP4).
Quality bar: Release build green; full solution suite 12,853 passed / 4
skipped / 0 failed (baseline 12,770/4/0 post-OP2 — 83 new tests added,
zero regressions).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Blast lens: the unguarded Type-8 arm re-classes five pre-existing
elements in four shipped panels (vendor backdrop sprite stops drawing;
character sheet + spellbook swallow clicks; IUiDatStateful propagation
severed), the Type-5 guard's premise is factually wrong (ten shipped
elements author non-empty 0x64), and the 27 stale fixtures make the
harness structurally blind to both — the green suite was true but
meaningless. Merge semantics, fixture provenance, hermeticity, and the
children-attached hook all verified clean.
Mechanism lens: readers decode correctly through the canonical
effective-state path; Type 8's real retail class is UIElement_Panel
(Update @0x0046BD00 — switching behavior confirmed faithful); both
claimed structural identities (UIOption_Slider = horizontal scrollbar,
UIOption_Menu = UiMenu shape) CONFIRMED from fixture fingerprints;
U10 closed (0x10000521 is the bitfield row template, consumed by
CreateChildren @0x00485DF0 via AddItemFromTemplateList(0)); TS-72 is
backwards — the toggle math is decomp-confirmed right, IsSet's all-bits
predicate is confirmed wrong (retail Refresh checks ANY mask bit).
Missing: GetChildRecursive resolution, no-0x32 means NO default switch,
SetupTabPageHash's malformed-entry filters.
Rework round 1 follows: UiTabControl/UiTemplateListBox become
UiDatElement subclasses with dormant behavior (controller-activated),
bitfield rows build from the authored template, IsSet goes any-bit,
all 32 fixtures regenerate as the acceptance gate.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Ships the two new widget primitives the retail Options panel needs plus the
four remaining UIOption_* factory mappings, so every tab page (OP3-OP6) has
somewhere to mount.
- ElementReader/ElementInfo gain three new dat-property readers, following
the existing effective-state-resolution pattern (never a per-state
first-wins scan, per the round-5 N1 lesson): the Type-8 tab table
(property 0x2E -> TabTable), a ListBox's row-template list (property
0x64 -> TemplateList), and scrollbar linkage (property 0x72 ->
ScrollbarElementId). LayoutImporter gains one hook
(IUiChildrenAttachedListener) so a widget can resolve cross-references
its own dat properties name by id once its subtree actually exists.
- UiTabControl (Type 8): switches exactly one page-slot child visible,
syncs each tab button's Open/Closed state via the existing
RetailTabBinding helper, and honors the authored default tab on mount.
- UiTemplateListBox (Type 5 with an authored template list): wraps a
UiScrollablePanel viewport (sealed, so composition not inheritance) and
ports AddItemFromTemplateList(index) — the resolver seam a page
controller wires with real DAT access via the SAME
LayoutImporter.ImportInfos(dats, layoutId, elementId) overload
RetailDialogFactory already uses for its catalog LayoutDesc.
- DatWidgetFactory maps the four remaining UIOption_* widgets, each
verified against the regenerated options_2100002B.json fixture before
writing any code: 0x10000037 (Slider) is structurally an ordinary
horizontal UIElement_Scrollbar, so it reuses BuildScrollbar directly;
0x10000038 (Menu) is structurally identical to the vendor category
dropdown UiMenu already models, so it reuses `new UiMenu()` like the
Type-6 case; 0x10000036 (CheckboxSlider) composes an existing
UIOption_Checkbox child + UIOption_Slider child via the new
UiOptionToggleSlider wrapper; 0x10000044 (CheckboxBitfield64) authors
zero children in the dat (every row is added at runtime via retail's own
AddChild(lowMask, highMask, label, tooltip) call shape), so it's a new
UiCheckboxBitfield64 composing UiButton per row. No new drawing code
anywhere in this set.
- Five new committed fixtures (options_2100002B/2100002A/21000028/
2100005C/21000029) plus 25 new conformance tests pinning the tab table
(4 entries, Gameplay default), all three template arrays, scrollbar
linkage, every new widget-type mapping, and a UiTabControl behavioral
test (switch -> exactly one page visible, click-through the tab
button). The Character ListBox's authored 6-header/49-toggle shape
(lane B section counts) is proven reachable end-to-end through
AddItemFromTemplateList against the committed fixture.
- Regenerating fixtures also touched 27 PRE-EXISTING, unrelated fixtures
(an Outline/OutlineColor field pair added by an earlier commit,
bcc34ee3, that predates when those fixtures were last regenerated).
Per the slice contract, that drift was NOT committed — reverted back to
HEAD, only the five new Options-panel fixtures are new files here.
- Filed TS-72: UiCheckboxBitfield64's click-toggle bit math (AND/OR
set/clear semantics) is a documented approximation — the decompiled
excerpt this campaign pulled covers UIOption_CheckboxBitfield64::Apply's
WRITE side, not its own click-handler's bit math. Flagged for OP5 (the
Chat tab controller, the first consumer that reaches the wire) to
verify against the real decomp before any live transaction depends on
it; nothing user-reachable can observe this yet.
Full Release suite: 12,770 passed / 4 skipped / 0 failed (was 12,745/4/0
post-OP1 — 25 net new tests, zero regressions).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Mechanism lens: 53/53 table rows verified four independent ways; golden
vector recomputed byte-for-byte; 15/15 citations resolve. MUST-FIX: TS-71's
deferral rationale asserts a nonexistent obstacle — both hosts already
funnel one Runtime tick seam (LiveSessionController.Tick), so the 480 s
timer + logout flush wire with zero host edits. SHOULD-FIX: port
CPlayerModule::OnChanged cases 2/0x12 (fellowship mutual-exclusion emits a
second 0x0005); add the id-by-id 53-row word/mask pin.
Blast lens: CH3/CH4 seams bit-identical; routes single-write; reset clean;
the blob echo reads canonical J4.3/J4.5 owners (the important negative).
MUST-FIX: SaveOptions before the PlayerDescription seed would flush CLIENT
DEFAULTS over server options — needs a HasServerSeed latch (set by
Replace, required by TryFlush, cleared by ResetSession). SHOULD-FIX: router
test substitutes a fake binding for the production seam; flush callback
runs under _dirtyGate (deadlock with the router gate once the timer
wires); ledger blind to IsDirty; SaveOptions result encoding differs
between adapters; Replace leaves stale dirty state; no cross-check between
PlayerDescriptionParser enums and CharacterOptionTable.
Fix round follows as one consolidated pass.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The retail Options panel (Campaign OP) needs a Runtime-owned option map
covering all 53 PlayerOption ids and the real batched SetCharacterOptions
(0x01A1) blob before any UI can be built on top of it. Today's surface only
modeled 6 ListenTo*Chat ids and the 0x01A1 builder was a malformed 16-byte
stub (deleted at Campaign CH slice CH3, docs/research/2026-08-09-chat-side-
channels-vs-ace.md).
- CharacterOptionTable.cs: the ONE typed table, PlayerOption id (0x00..0x34)
-> (Options1/Options2 word, mask, IsAutoSave, ClientDefault), transcribed
from acclient.h's verbatim CharacterOption/CharacterOptions2/PlayerOption
enums and byte-verified against IsAutoSaveOption @0x0059A600 (the 21-id
auto-save table) and GetDefaultOptionValue @0x005D2A30 (the Defaults-
button table). Reconstructing CharacterOptions1/2 defaults from the
ClientDefault column independently reproduces 0x50C4A54A / 0x00008700,
cross-confirming the id-mask mapping. CharacterOptionId (SocialActions.cs)
widened from 6 to all 53 ids to match.
- RuntimeCharacterOptionsState: SetOptionBit now resolves through the full
table (was a 6-case switch). New TrySetOption is the ONE shared local-
write-then-send/dirty seam — mirrors CPlayerModule::OnChanged exactly:
write the bit locally first, then either send 0x0005 immediately (auto-
save ids) or MarkDirty for the batched blob, no-op on an unchanged value
(retail's own early-return) or an unmodeled id. New dirty model (IsDirty/
FirstDirtiedAt/MarkDirty/TryFlush/TryFlushIfAutoSaveDue) uses an injected
TimeProvider so it's fully unit-testable without a live clock.
- Both IRuntimeCharacterCommands.SetSingleOption adapters (Direct + Current)
now route through TrySetOption instead of duplicating the write; this
fixes the headless local-write gap the OP1 research flagged (the direct
adapter previously sent the wire message without writing the bit first,
same class of bug CH4 fixed for the graphical host). Both also reject an
id outside the table instead of silently accepting it. LiveSessionRuntime
Factory's SendSingleCharacterOption closure now delegates to the same
seam instead of duplicating write-then-send inline.
- New IRuntimeCharacterCommands.SaveOptions(generation) — the explicit
blob-flush verb (retail's SaveToServer(force: 0)) — wired end-to-end in
both adapters, including a new SaveCharacterOptionsRuntimeCmd on the
graphical router.
- SocialActions.BuildSetCharacterOptions + WorldSession.SendSetCharacterOptions:
the real PlayerModule::Pack body per the wire research's field-by-field
layout — header always 0x460 OR'd with 0x001/0x008 when shortcuts/desired
comps are non-empty, favorite spells always 8 lists, never sets 0x100 or
0x200. Echoes last-parsed shortcuts/favorites/desired-comps/spellbook
filters (via new CharacterOptionsBlobSource) instead of zeroing them.
Conformance: a hand-computed golden byte vector (not generated by the
builder under test — the CH3 builder died of tests that pinned a wrong
shape and looked green) plus a round-trip through PlayerDescriptionParser.
Contract deviation: the 480 s auto-save timer and the flush-before-logout
trigger are implemented as fully-tested pure state-machine logic
(TryFlushIfAutoSaveDue) but are NOT wired into either host's live per-frame
loop or graceful-shutdown sequence in this slice — only the explicit
SaveOptions verb is production-wired. Wiring the timer touches App's
UpdateFrameOrchestrator graph and Headless's tick loop (outside this
slice's Runtime/wire-layer scope); wiring logout risks the already-fragile
graceful-shutdown sequence CLAUDE.md flags. Filed as TS-71 per the plan's
own escape valve ("target: not deferred" with a register row if deferred).
Also filed: AP-193 (the 0x34 HearPKDeathMessages id/mask is ACE-sourced,
unverifiable against the 2013 binary) and AP-194 (GetDefaultOptionValue's
table disagrees with the constructor default for ConfirmVolatileRareUse/
ShowHelm/ShowCloak — retail's own quirk, reproduced not fixed).
Tests: table completeness x53, auto-save/client-default split pinned
id-by-id against the byte-verified tables, unknown/reserved-id rejection
(0x35/0x36 landmines), local-write-then-send on both adapters + the router,
the dirty/flush state machine, SaveOptions, and the wire golden vector +
PlayerDescriptionParser round-trip. Full Release suite: 12,745 passed / 4
skipped / 0 failed (baseline 12,611/4/0 — slice adds 134 passing tests,
zero regressions).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fable-authored plan from the four seam-verified research lanes. Eight
design decisions stated per the campaign directive (reactable at gates):
the retail panel is acdream's ONE in-client settings surface (the F11
SettingsPanel was never rendered post-V11 — lane D's verified correction),
retail's 21-vs-batched wire split ships exactly with all three flush
triggers, the 2015-only PK-deaths row ships wire+store with a register
row, Configure Keyboard is the campaign's only rebind screen (DAT
ActionMap data, keybinds.json persistence), dead support-URL buttons
short-circuit to their own byte-verified retail failure strings, Exit to
Character Selection adapts to Exit Game behind retail's confirm dialog +
mid-air refusal, lane B group C re-points to server truth per CH3
precedent, and bots declare options by NAME in the K1 strict schema.
Slices: OP1 Runtime map/dirty/blob (+ the headless local-write fix both
lanes found), OP2 the two missing widget primitives + UIOption mappings,
OP3 shell + Gameplay tab vertical, OP4 Character tab, OP5 Chat tab, OP6
Config tab, OP7 headless characterOptions, OP8 Configure Keyboard, OP9
closeout + connected test script. U1 closed during planning: the Defaults
button restores DAT DBPropertyCollection values
(UIOption::InqDefaultGameplayOptionProperty @0x004ef8d0,
GetDIDFromEnumStatic(0x16, 2)).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Final of four Opus research lanes for the retail Options panel campaign:
- One LayoutDesc 0x2100002B owns the tab control (0x10000208, Type 8),
the four mounted tab pages, and the seven option-row templates. Pages:
gmGameplayOptionsUI 0x2100002A/0x10000202 (default tab),
gmCharacterSettingsUI 0x21000028/0x100001F9 (InitOptions @0x004A02F0),
gmChatOptionsUI 0x2100005C/0x1000050A (@0x0049FC60),
gmConfigUI 0x21000029/0x100001FF (@0x0049E400). Tab declarations are a
data table (property 0x2E structs {0x30 button, 0x31 page, 0x32
default}); rows build via UIElement_ListBox::AddItemFromTemplateList
against authored template lists in ListBox property 0x64 (all three
template arrays decoded).
- Open path: input action 0x1000001A ToggleOptionsPanel, retail-default
F11 (VERIFIED in retail-default.keymap.txt:148); toolbar button
0x1000019B authors P0x12=0x1000001A (VERIFIED in the committed
toolbar fixture); host is gmFloatyPanelUI 0x2100006E slot 0x1000018D —
the same floating shell CH6 ports.
- Apply/Reset/Defaults are PER TAB, and clicking an LED APPLIES
IMMEDIATELY (SetCurrentValue -> Apply(1)); Apply commits the undo
baseline + CPlayerModule::SaveToServer (flushes 0x01A1 if dirty);
Reset reverts to baseline; Defaults applies live without committing.
Hiding a page auto-reverts uncommitted edits; showing auto-applies.
- Chat tab fully enumerated (2 linked opacity sliders + 5 per-window
filter blocks, 13 checkbox masks byte-decoded; main window omits the
Gameplay checkbox - 12 rows vs floaties' 13). Config tab = 6 sections
/ 27 rows, ALL UserPreferences.ini-backed, nothing on the wire.
- Two new widgets needed: Type 8 tab control, Type 5 template-list
ListBox (both also needed by Configure Keyboard). U1: the Character
tab's Defaults behavior is genuinely unestablished (never calls
SetDefaultValue). U3: the 50th row (PK deaths) is a later-build
addition (DAT string exists, hash-verified). U7: Urgent Assistance /
Report Abuse ShellExecute dead support URLs — register-row candidates.
Research phase complete: all four lanes landed and seam-verified.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Third of four Opus research lanes for the retail Options panel campaign:
- Gameplay Options tab (gmGameplayOptionsUI::ListenToElementMessage
@0x0049E110): five of seven buttons have code handlers. Exit to Char
Selection -> local EndCharacterSession notice -> confirm dialog ->
airborne refusal (byte-verified) -> 0xF653 logoff; Exit Game -> the
epilogue path, no confirmation; Urgent Assistance / Report Abuse are
WEB LINKS in the EoR build (same support-site URL, byte-verified) —
the legacy wire paths still exist (0x0140 AbuseLogRequest: ACE names
it but has NO handler; Help channel 0x400 broadcast: ACE handles it);
Use Mouse Turning Settings is a 6-option macro (SetMouseTurningDefaults
@0x0049E8F0), not a screen — five client-local prefs + one server bit
(PlayerOption 0x31); Configure Keyboard / In-Game Help have no element
handler in the class (help = external ACHelpPlugin.dll via keystone).
- Configure Keyboard (gmKeyboardUI): six ActionClass list boxes, rows
from DAT ActionMaps (DBO 0x27), all 19 ID_InputMap_* strings
byte-verified; N-way cross-map conflict handling; storage is a LOCAL
.keymap file named in UserPreferences.ini, never wire-synced; Reset
reloads the DAT master maps (DIDs 0x14000000/0x14000002, which
tools/dump-keymap already extracts). retail-default.keymap.txt is a
user-saved keymap, not the DAT default.
- LOAD-BEARING CORRECTION (verified at the seams): the F11 SettingsPanel
is NOT rendered anywhere post-V11 — ToggleSettingsPanel() is an empty
no-op, the only IPanelRenderer implementation is the test fake, and
SettingsDevToolsComposition documents the keybinds.json fallback. The
retail Options panel is therefore acdream's FIRST shipping in-client
settings surface, and Configure Keyboard is the ONLY rebind screen —
it also clears Campaign V's carried panel debt (#258 adjacent).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two of the four Opus research lanes for the retail Options panel campaign
(docs/research/2026-08-10-settings-track-handoff.md):
- Lane B: the complete Character-tab option map. 50 rows / 6 groups
decomp-authored from gmCharacterSettingsUI::InitOptions @0x004a02f0
(the screenshots' PK-death row is 2015-client-only; the 2013 enum caps
at 0x33). Wire routing is retail's byte-verified lookup table
CPlayerModule::IsAutoSaveOption @0x0059a600 — ~21 ids send 0x0005
immediately, the rest dirty the module for the batched 0x01A1. Retail's
Defaults-button table reconstructs Options1 = 0x50C4A54A exactly
(independent confirmation of ACE's constant) and Options2 = 0x00008700
vs ACE creation's 0x00948700 (a real client-vs-server distinction, not
a bug). Per-option ACE handling + acdream consumer inventory included.
- Lane C: the real 0x01A1 body is PlayerModule::Pack @0x005D45C0
(builder CM_Character::Event_CharacterOptionsEvent @0x006A10C0), flag
enum PlayerModulePackHeader verbatim at acclient.h:7835;
SetPackHeader @0x005D44A0 always sets 0x460 and never 0x02/0x04/0x10/
0x80, so ACE's extra reader branches are dead legacy. Flush triggers:
Apply, logout, 480 s autosave. ACE stores options words raw, discards
the rest, refuses only pre-LoginComplete; unknown option ids THROW.
CH3 post-mortem: the deleted 16-byte builder put a CharacterOptions1
word in the section-flag slot.
Both lanes independently converged on the same latent defect: the
headless DirectGameRuntimeCommandAdapter.SetSingleOption sends the wire
but skips the local Options.SetOptionBit write the graphical path does
(LiveSessionRuntimeFactory.cs:348) — the CH4 stale-membership-gate bug
class reproduced on the bot side. Flagged for the campaign plan, not
fixed here (research-only lanes).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Collects the post-gate polish left uncommitted by the killed round-5 agent
(S1/S3 + review fixes N1/N3/N4) and completes the missing S2 half:
- S1: UiText multi-line transcript + colored-run label now submit EVERY
line/run's outline pass before ANY fill pass, matching retail's
UIElement_Text::DrawSelf @0x00467aa0 whole-block walk. DrawStringDatPass
is exposed for block-level batching; single lines keep DrawStringDat.
- S2 (completed this commit): authored outline 0x21/0x22 now reaches every
text-bearing widget — UiButton, UiDatElement, UiField, UiMeter, UiMenu,
UiCatalogSlot — seeded from the element's effective-default state exactly
like UiText (BuildButton lifts the label-bearing Text child's authored
value first, same chain as the label color). Per-STATE outline switching
(dialog/character/combat buttons author 0x21 in state 0x3 only) is NOT
ported — filed as register row AP-192 in this commit.
- S3: ChatWindowController reconciliation comment corrects the misread
indicator action ids 0x10000514-17 -> 0x10000114-17 and re-attributes the
id-coincidence to the pagination widget's m_prevButton/m_nextButton, not
gmFriendsUI; register + window-shell research doc corrected to match.
- N1: LayoutImporter's duplicate per-state any-state-first-wins 0x21 read is
deleted — ElementReader.ApplyCanonicalLegacyProjection's DirectState-then-
effective-default resolution is the single source (the duplicate would have
lit state-0x3-only outlines permanently once S2 widened consumption).
- N3: the outline pass tints with the outline color's OWN alpha, not the
fill's (retail tints m_curOutlineColor and m_curTextColor independently).
- N4: the outline-inflated glyph SOURCE rect is clamped to the atlas bounds
with matching dest shrink, porting CreateCharRectPair @0x00441480's edge
behavior — edge glyphs crop instead of sampling a neighbour's texels.
Full Release suite: 12,610 passed / 4 skipped / 0 failed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Transcribes the user's four retail screenshots, inventories the existing
plumbing (CH3 option wire, CH6 UI machinery, the F11 surface), the
research questions, and the binding process lessons from Campaign CH.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Round 5 (2026-08-10): 'Good, looks good now.' Carried tail recorded in
the plan header and CLAUDE.md.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Campaign CH round 4, user-gate items 1+2. Root cause: retail ships a
second (background) glyph atlas per font, dilated 2px on every side,
plus two border-pixel scalars (Font.NumHorizontalBorderPixels/
NumVerticalBorderPixels) that acdream's font reader never read — so
even the pre-existing outline parameter drew almost nothing once
enabled. Landed together (either half alone is a no-op or a
regression):
- UiDatFont carries BorderX/BorderY from the DAT font resource.
- UiRenderContext.DrawStringDat inflates the background blit's source
and destination rect by that margin and restructures into retail's
exact two-pass whole-string outline-then-fill model
(UIElement_Text::DrawSelf), plus the 8-neighbour +-1px fallback for
fonts with no background atlas. Corrects the stale "property 0xd"
comment to the real ids, 0x21 (Outline) / 0x22 (OutlineColor).
- LayoutDesc property 0x21/0x22 import (ElementInfo.Outline/
OutlineColor, LayoutImporter.ReadState, ElementReader.Merge/
ApplyCanonicalLegacyProjection, DatWidgetFactory.BuildText) so every
authored-outline element across the DAT set is correct at once.
- SpewBox: RetailFontId corrected from a round-3 heuristic
(0x40000025) to the actually-authored 0x40000001 (18px bold serif),
Outline=true set on the controller's UiText. Fill colour stays the
user-gate-round-1-pinned yellow — font atlases are alpha-only
(PFID_A8), so there is no baked shading that could explain the
screenshot's gold as anything other than the outline itself.
- Chat transcript: default fill now seeds from its authored
ARGB(255,204,204,204) instead of an unrelated color-table slot
(ChatTranscriptRenderer.BuildLines takes the transcript's own
DefaultColor as a parameter); the 34-entry LogTextType table is
untouched, and every existing CH1 conformance test stays green
unmodified.
Regenerated the committed chat_2100006f.json fixture from the real
installed DAT, confirming end to end (not by missing-field default)
that the transcript carries no outline.
Tests: font-reader border fields + inflation math pinned against the
real DAT font, two-pass draw ordering/tint/inflation via a new
TextRenderer.DebugSpriteSegmentVerts test seam, property 0x21/0x22
import at both the ElementReader.Merge and StateDesc-property layers,
SpewBox font/outline, and the chat default-shade seed with the color
table proven untouched.
Full Release suite: 12,610 passed / 4 skipped / 0 failed
(AcDream.slnx, complete solution).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Item 3 (#364): every honesty marker is now gone from user-visible /help
text. AllegianceOverview/HouseOverview's "[IMPLEMENTED]" tags and trailing
"Subcommands NOT marked..." sentences, and Day/Log/Render/Motd's appended
"NOT YET IMPLEMENTED in acdream" tails, are removed; the underlying retail
text is corrected/completed against the pseudo-C's own pristine
consolidated data dumps (Log and Motd had been silently truncated; Render
was entirely acdream-authored and is replaced with the real retail usage
string). The three PARTIAL /help group topics (channels/chatting/commands)
are now COMPLETE verbatim listings: HelpStupidChannelHack's three
"vtable slot" operands, previously believed undecodable, are the same
pooled/mislabeled-data artifact this campaign has hit before (AP-113's
precedent) — reading the function's own disassembly for the push imm32
preceding each constructor call resolves all three directly. messagetypes
is now a real ported construction (IsLegalChannel's 14-id whitelist +
LogTextTypeToString's name table + the exact join/wrap format) instead of
an acdream summary. Register row AP-184 retired.
Item 5: the main window's 1/2/3/4 indicator buttons now toggle their
floating chat window on click, per the user's retail memory overruling
the earlier decomp-only reading. UIElement_Button::HandleButtonClick has
its own generic click-driven action dispatch (property 0x12) reaching the
same DoVisibilityToggleAction the Alt+1..4 keybinds use; the button
fixture confirms this half is genuinely armed, but the floating-window
fixture authors no matching listener-registration property, so the
generic mechanism has no proven target in the data on hand. Per
CLAUDE.md, the user's retail memory is the axiom regardless:
ChatWindowController.BindIndicatorClicks wires each indicator's click
through the same ToggleFloatingChatWindow chokepoint the keybinds use,
as explicit user-directed retail behavior. SetIndicatorOpen stays the
sole writer of the Selected mirror so the visual stays consistent
through the click round trip.
Full reconciliation in docs/research/2026-08-09-chat-retail-window-shell.md
§1.4. Campaign plan gets the round-4 findings section; items 1+2
(text-style) are under parallel research, item 4 passed, item 6 deferred
to the settings track.
Suite: 12,579 passed / 4 skipped / 0 failed (Release, complete solution),
up from baseline 12,553/4/0 — net +26 tests, zero regressions.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
#368's entry now records the fix mechanism (dedicated
acdream-headless-update thread owning Start + every scheduler turn;
synchronous TimeProvider-timer scheduler loop; guard untouched, zero
shared Runtime changes) and the 3/3 live-ACE verification vs the 3/3
pre-fix quarantines. The #365 entry and diagnosis doc get dated
pointers: their open question is answered — the airborne residual
persists with threads provably single, refuting the
unsynchronized-thread hypothesis — and is split off as #370 with the
evidence and starting points.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Runtime's contract is ONE update thread per session for its whole
lifetime — RuntimePhysicsState.EnsureCollisionMutationThread enforces it
for collision generations (bind-first-mutator, refuse migration), and
the entity directory, physics publication, and placement channel all
document the same assumption without enforcing it. The graphical host
satisfies the contract with its game-loop thread. The headless host
violated it structurally: HeadlessProcessScheduler.RunAsync drove ticks
through await Task.Delay(...).ConfigureAwait(false), and a console app
has no SynchronizationContext, so each resumption could land on a
different ThreadPool worker. Any collision generation spanning two waits
then tripped the guard — reproduced 3/3 against live ACE at
[wake] begin gen=1 (see docs/ISSUES.md #368).
Fix shape (headless-only; zero shared Runtime changes, so the graphical
host is untouched by construction):
- HeadlessProcessScheduler.Run(CancellationToken) replaces RunAsync: the
same deadline math, counters, and NormalizeTimerDelay clamp, but fully
synchronous on the calling thread. Waits go through one rearmed
TimeProvider timer signalling an event (WaitHandle.WaitAny with the
cancellation handle), so the loop never leaves its thread and returns
normally on cancellation.
- HeadlessProcessHost.RunAsync now spawns one named dedicated thread
("acdream-headless-update") that owns Start (the live connect
transaction), every scheduler turn, and the post-loop resource
captures, bridged to the same Task<HeadlessExitCode> via a
TaskCompletionSource. Start had to move too: the first
collision-mutating call can happen during connect, and binding the
guard on the caller's thread would trip the very first dedicated tick.
Disposal stays on the lifecycle thread, which the Runtime teardown
path explicitly supports (ResetSessionPhysics's doc comment) and every
prior graceful-teardown run exercised.
New test ProcessHostRunsStartAndEveryTickOnOneDedicatedUpdateThread
pins the contract: Start and every tick share one thread that is not
the RunAsync caller's, across real timer waits (RED pre-fix — Start ran
on the caller's thread). SystemTimerCadenceDoesNotBusyLoopBetweenTurns
moved to the synchronous seam and still bounds WaitCount.
Verification: Headless suite 97/97; full Release suite 12,554 passed /
4 skipped / 0 failed; three live jump-probe runs against local ACE
(ACDREAM_PROBE_PARK=1) each crossed the collision generation cleanly
(205 entities hydrated, zero faults, policy completion, ACE-confirmed
graceful logout, converged disposed sample, exit 0) — pre-fix the same
recipe quarantined 3/3. The jump-airborne timeout persists 3/3 on the
fixed tree, refuting the #365 diagnosis's "threading artifact"
hypothesis for it — filed separately as #370.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
SHOULD-FIX 1: RetailClientCommandCatalog's ~45 catalog leaf verbs were
showing acdream-authored Summary text for /help <verb> instead of
retail's own Detail_HelpType(2) text. Byte-swept every Help* handler
against the PDB-paired acclient.exe (verified MATCH), confirmed each
Detail/Summary branch by reading the actual decompiled if/else shape
(address order and string length both proved unreliable alone), and
fixed a sweep_weenie_strings.py 800-char truncation bug that silently
dropped several longer Detail branches. Resolved every ambiguous
CmdHashData-registered verb (hor/hr/hom/hoa/alh/ah/friends_add/
friends_remove/squelch/unsquelch) by reading for Binary Ninja's
nullptr-4th-arg decompiler artifact instead of trusting it. Coverage:
42 of 47 distinct catalog Definitions verbatim-extracted, 4
confirmed-null (index/clist/on/off register with a genuinely null help
pointer — DoHelp falls to UnknownCommand for these, now reproduced),
1 honest UNVERIFIED (messagetypes builds its text from a runtime enum
table, not a static string). ChatCommandRouter now prefers retail
Detail text over the catalog summary; RetailCommandHelpTable's class
doc no longer overclaims its own scope.
SHOULD-FIX 2: extracted the a5a7eb4f-class OnInterfaceText wiring into
a testable CreateChatViewModel method and added
ComposedChatViewModelWiresOnInterfaceTextToSpewBox, which the prior
FakeFactory-based test suite could never exercise.
SHOULD-FIX 3: retires register row AP-113. DoLifestone/DoMarketplace
print their own 0x1A refusal text (byte-recovered, UTF-16LE) instead
of falling through to the generic 0x26 fallback; ChatCommandRouter's
comment corrected to state the fallback's real scope.
SHOULD-FIX 4: corrected the divergence register's stale AP section
header sentence about AP-190's opacity default (refuted by cc582899).
NITs: (a) HeadlessStaticStateAudit routes through the injected
HeadlessDiagnosticWriter instead of Console.WriteLine; (b) a bounded
300-pump liveness diagnostic on the IsQuiescent conductor gate (no
retry, no behavior change); (c) fixed the #365 hydration test's doc
comment contradiction against diagnosis §8; (d) the 0x26 fallback
dispatches on WeenieErrorMessages' own Type instead of hardcoding
ClientLocal.
Full Release suite: 12,553 passed / 4 skipped / 0 failed (baseline
03404b71: 12,542/4/0; net +11 tests, zero regressions).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ChatVM gains an OnInterfaceText hook + ShowInterfaceText(text), the
App-layer composition wires it to RuntimeCommunicationState.AddText,
and ChatCommandRouter routes every retail-0x1A command refusal through
it instead of the chat log's 0x00 sink. UI.Abstractions still never
references Runtime directly; unwired hosts (headless, tests) fall back
to the chat log tagged ClientLocal so no text is ever silently lost.
Reclassified per register row AP-183 (DoChannelList/On/Off, DoAllegiance,
DoHouseAvailableList — the last also corrected to retail's own bad-house-
type string instead of a synthesized "Usage:" line) and newly wired two
sites that previously showed nothing at all (DoStupidChannelHack's bare
legacy-channel-verb refusal, DoReply's message-but-no-last-teller
refusal). The generic bad-args fallback now resolves WeenieErrorMessages
0x026 ("That is not a valid command.", retail's HandleFailureEvent(0x26))
instead of synthesizing "Usage: {Usage}". DoSpeaker/DoEndurance/DoTitle
are untouched — already correct at 0x00.
Also closes#367 (DoHelp's "Unknown command" fallback and the degenerate-
prefix refusal now reach the SpewBox too) and retires register row
AP-186, whose own filing proposed exactly this seam shape.
Full Release suite: 12,542 passed / 4 skipped / 0 failed (baseline
12,466/4/0 at ff2784ea).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Root cause, confirmed by a full-production-wiring repro test rather than
guessed: InputAction.AcdreamToggleAudioMute was bound to Ctrl+M only in
KeyBindings.AcdreamCurrentDefaults() -- the pre-K.1c WASD-only preset,
whose own doc comment says it is preserved solely as a regression anchor
and is explicitly NOT the GameWindow startup source after K.1c.
KeyBindings.RetailDefaults() -- what KeyBindings.LoadOrDefault actually
falls back to when no keybinds.json exists on disk (the verified state
on the affected machine) -- has its own "Acdream debug actions" block
(Ctrl+F1/F2/F3/F7/F8/F9/F10, Ctrl+Shift+F) but never carried the Ctrl+M
mute binding over into it. The live dispatcher therefore had no Ctrl+M
entry in its binding table at all -- not a modifier-matching bug, not a
scope bug, not a retained-UI-capture bug. Same class as the a5a7eb4f
jump fix (two construction paths, one wired to production), except here
it's two default-binding-set methods rather than two controller
instances, and the binding was simply added to the wrong one. This also
explains the prior "loaded 152 bindings both before and after" mystery:
the count correctly didn't change, because the earlier addition went
into a method nothing in production loads or counts.
MuteChordDispatchTests.CtrlM_WithNoWidgetFocused_FiresAcdreamToggleAudioMute
reproduces the full production shape (real RetailDefaults(), the
dispatcher's actual default [Always, Game] scope stack -- production
never calls PushScope/PopScope anywhere, grepped clean across
src/AcDream.App -- and a synthetic Ctrl+M keydown) and failed with an
EMPTY fired collection before this fix, which is what pinpointed
"missing table entry" over the other ranked hypotheses. A second test,
CtrlM_WhileAnyWidgetHoldsKeyboardFocus_IsSuppressed, pins a separate but
real mechanism found along the way (InputDispatcher.OnKeyDown returns
before FindActive when WantCaptureKeyboard is true, and production wires
that to "any focused widget", not just chat text entry) that was ruled
out as #358's cause since the baseline repro failed with nothing
focused.
Fix: KeyBindings.RetailDefaults() now also binds Ctrl+M to
AcdreamToggleAudioMute. Retail's own keymap has no Ctrl+M binding, so
this doesn't collide with anything retail-faithful. #358 closed in
ISSUES.md with the confirmed mechanism; connected verification (does
Ctrl+M actually mute in a live client) is still owed -- this session's
hard constraints excluded client launches.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
BLOCKER: ChatSettings.DefaultOpacity shipped retail's base ChatInterface
value (0.5) as ONE shared global default applied to every
RetailWindowManager-registered window, not just the four floating chat
windows retail itself fades. That faded the whole out-of-box registered
UI (radar, vitals, toolbar, main chat, ...) to 50% opacity, including
several windows that can never take keyboard focus and so were stuck at
0.5 permanently. Fixed to gmMainChatUI's 1.0/1.0 override
(0x004CD0F0) instead — retail-identical opaque presentation for the 11
non-chat windows and the main chat window; only the four floating chat
windows now diverge from retail's 0.5-while-idle default, and the
Settings -> Chat transparency slider remains fully user-settable.
AP-190 reworded and gains two new decomp-verified clauses: (3) retail
eases opacity toward its target by 5% of the delta per tick
(ChatInterface::ListenToGlobalMessage @0x004F3840, armed from the focus
element-messages at @0x004F5275) where acdream snaps -- deferred, needs
a UI frame-tick hook the opacity controller doesn't have; (4) retail's
focus predicate is the chat ENTRY FIELD specifically
(ChatInterface::IsTextEntryFocused @0x004F30A0) where acdream uses
any-focusable-descendant. Both findings + the pre-existing UiMenu.cs
PushAlphaAbsolute(1f) popup bypass are folded into the window-shell
research doc's opacity section.
NITs: fixed the stale "text bypasses the alpha" comment in
UiElement.DrawSelfAndChildren (CH6c already routed DrawStringDat/
DrawString through the same ApplyAlpha chokepoint as sprites/rects);
added RetailWindowManager.WindowUnregistered + wired
RetailWindowOpacityController to detach and forget a window unregistered
while it held focus (previously only Dispose detached, leaking any
window unregistered mid-focus for the rest of the session); added
post-Dispose no-op guards to the three Set* opacity mutators; added a
DrawString (BitmapFont path) alpha regression test and a DrawStringDat
outline/background-pass alpha test (the existing tests only ever
exercised the foreground/fill pass).
Also fixes RuntimeSettingsControllerTests.SettingsViewModelSavePreserves
SectionAndTargetOrder's now-stale "target-chat-opacity:0.5:1" expectation
(caught by the full-suite run this fix requires) to match the new 1.0
default.
Campaign ledger CH6c row updated to APPROVE-WITH-FIXES.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Retail's ChatInterface::SetOpacity (0x004F3120) fades the WHOLE composited
window surface with one alpha; UiRenderContext.ApplyAlpha already gated
DrawSprite/DrawRect/DrawFill (since 1da697ec, pre-CH6) but DrawStringDat and
DrawString still passed applyAlpha:false, so text stayed sharp over a
translucent window. Both now route through the same chokepoint.
RetailWindowOpacityController (new) subscribes to a new
RetailWindowManager.WindowRegistered event and drives every registered
window's live Opacity from keyboard-focus state, applied to EVERY window
(chat, floaties, vitals, toolbar, ...) rather than retail's ChatInterface-only
scope — register row AP-190, retiring the stale AP-40 "fixed 0.75, no focus
transition" row in the same commit.
Verified retail's shipped opacity defaults from the decomp (constructor
literals, no cdb needed): the base ChatInterface ctor sets
DefaultOpacity=0.5/ActiveOpacity=1.0, kept unmodified by the four floating
windows; gmMainChatUI's own ctor overrides the main window to 1.0/1.0
(always fully opaque). acdream ships one shared global default (0.5/1.0)
rather than replicating the per-class override — also AP-190. The linking
invariant (raising default above active drags active UP; lowering active
below default drags default DOWN — never a clamp) is ported verbatim as
ChatOpacityLink in AcDream.UI.Abstractions, shared by the live controller
and the new Settings -> Chat tab's two linked opacity sliders.
Persistence: ChatSettings.DefaultOpacity/ActiveOpacity round-trip through
SettingsStore; Save pushes both through IRuntimeSettingsTargets.SetChatOpacity
into the live controller, no restart required.
Rider (CH6a/b re-review): strengthened the grip-media regression guard past
a bare SpriteFile != 0 check — ChatLayoutConformanceTests now drives each
live grip through a real UiRenderContext/TextRenderer (backed by the
in-memory RecordingGpuDevice test double) and asserts the draw call chain
actually queued sprite geometry, via a new TextRenderer.DebugSpriteSegments
test-only accessor.
Full Release suite 12,459 passed / 4 skipped / 0 failed (baseline
12,420/4/0). No subagents, no client launches (session hard constraints);
pending the next connected user gate for visual confirmation.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The '(this commit)' placeholder appeared 13 times across historical rows;
only the CH6a/b rework references belonged to 1aa77099. Each historical
row restored to its true SHA.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Applies docs/research/2026-08-10-ch6ab-review-findings.md in full:
- BLOCKER 1: UiResizeGrip now carries its ElementInfo/resolve pair and
draws its own authored DirectState media (a synthetic parameterless
grip still draws nothing, preserving existing resize-drag tests).
DatWidgetFactory.BuildResizeGrip threads resolve through. All seven
live grips on the main chat window now resolve a non-zero sprite,
restoring the visible borders/corners CH6a silently dropped.
- SHOULD-FIX 2: ChatWindowState gains BroadcastTargetWindow, a sentinel
distinct from every real window id (0-4), fixing the bug where the
main window's explicit-addressing branch coincided with the broadcast
check (both were literal 0). SetFilter's main-window no-op is dropped
— the main window's filter is now genuinely settable. ChatWindowController
.Bind takes a ChatWindowState (the same canonical instance the floating
windows already share) and GetTranscriptLines builds a real accept
predicate instead of accept:null. Verified safe: ClientLocal (0x1A)
never reaches ChatLog (AddText routes it to the SpewBox and returns),
so nothing observable regresses.
- SHOULD-FIX 3: UiButton.SuppressSelfToggle stops the four chat-window
indicator buttons (DAT property 0x0B=true, no retail click handler)
from flipping their own Selected mirror on a stray click.
- SHOULD-FIX 4: generated and committed chat_floaty_2100005b.json from
the real installed dats; added the permanent RetailLayoutFixtureGenerator
entry. All three flagged FloatingChatWindowController assumptions
(input field, title bar, close button) are confirmed correct against
real data — no controller code changes needed. New finding: unlike the
main window, ALL EIGHT floaty border/corner elements are live Type-9
grips (the floaty's own title bar is its move handle), so a floaty
window resizes from every edge and corner.
- SHOULD-FIX 5: register row AP-189 documents the shared-500-entry/
200-line-tail vs retail's per-window 10,000-line scrollback depth gap.
- NITs 1-5: documented the filter-persistence-only-on-/saveautoui
asymmetry and the reconnect-preserves-filters intent; corrected the
research doc's modifier-mask mislabel and the "ONLY function" false
superlative; moved WrapText off ChatWindowController onto
ChatTranscriptRenderer, closing the circular dependency.
Full Release suite: 12,420 passed / 4 skipped / 0 failed (baseline
12,392/4/0 at 22020ef2; net +28 tests, zero regressions).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Mounts retail's four floating chat windows as always-resident, born-hidden
children per gmGamePlayUI::SetupChildren @0x004E9EC0, all sharing LayoutDesc
0x2100005B (window ids 0x10000505/0x1000050E/0x1000050F/0x10000510). New
FloatingChatWindowController (AcDream.App/UI/Layout) binds each window's own
widget tree — built fresh per instance from one shared imported ElementInfo
— reusing ChatWindowController's word-wrap + retail color-carry algorithm via
the extracted ChatTranscriptRenderer instead of duplicating it. A floaty
window has no talk-focus menu (research doc §2.2), so its entry field always
sends on Say; the mismatch against retail's possible shared-channel behavior
is UNVERIFIED and filed as #369/AP-188.
Runtime owns the per-window filter/open state: ChatWindowState (new,
AcDream.Core.Chat) seeds retail's exact PostInit defaults per window
(window 1 0x0000101C Speech/Tell/DirectSend/Emote, window 2 0x00040C00
Social/SocialSend/Allegiance, window 3 0x00080000 Fellowship, window 4
0x78000000 Turbine General/Trade/LFG/Roleplay) and implements the full
ShouldDisplay(windowId, targetWindowId, logTextType) display predicate from
ChatInterface::RecvNotice_DisplayFinalStringInfo @0x004F4640. It lives on
RuntimeCommunicationState.ChatWindows so every host borrows the same
instance. The main window's filter (0xFBFFFFFF, "no user filter") never
actually gates anything because its own explicit-address branch already
covers every broadcast line — that's why UpdateFromPlayerModule early-returns
for window 0 in retail, ported here by construction rather than a special
case.
Keybind wiring: InputAction.ToggleFloatingChatWindow1..4 and their
KeyBindings.RetailDefaults() chords already existed since Phase K.1c
(unwired until now). The MetaKeys table confirms retail's default is Alt+1
through Alt+4 (index 3 = bit 0x00000004, cross-checked against the same
file's Alt+A/D strafe and Alt+Enter/Tab/F4 rows). Routes through
GameplayInputCommandController -> RetainedGameplayWindowCommands ->
RetailUiRuntime.ToggleFloatingChatWindow -> the generic UiHost.ToggleWindow,
whose visibility-change event is the single chokepoint that syncs
ChatWindowState.SetOpen and mirrors the main window's 1-4 indicator button
regardless of what changed a window's visibility (keybind, close button, or
a restored layout).
A direct decomp read of gmMainChatUI::ListenToElementMessage @0x004CDA80 —
the only function in the whole binary that branches on a click message —
settles what the research doc had left as a hedge: it handles exactly
0x1000046f (max/min) and the talk-focus menu's selection message, with NO
case for 0x10000522-0x10000525. The four indicator buttons are PURE
one-directional mirrors in retail; clicking them does nothing.
ChatWindowController.SetIndicatorOpen ports this with no OnClick at all.
Corrected research doc §1.4 accordingly.
Persistence is local-only (register row AP-187; the retail 0x1000008C
GameplayOptions wire remains deferred to CH6f): window geometry and
open/visible state ride the existing generic RetailWindowLayoutPersistence
path for free once each window registers under its own WindowNames entry;
the four filter masks get a dedicated ChatSettings round-trip
(ChatWindow1Filter..ChatWindow4Filter, defaulting to the retail PostInit
constants) loaded at mount and saved alongside SaveLayout().
Tests: ChatWindowStateTests (defaults, TypeIsActive, the full display-rule
matrix, toggle/reset, revision counter), FloatingChatWindowControllerTests
(bind smoke tests against a synthetic 0x2100005B tree, per-window filter
routing, filter-change cache invalidation, fixed-Say submit), new
ChatWindowController.SetIndicatorOpen tests (Highlight/Normal state,
cross-window isolation, range validation), GameplayInputCommandController
routing for the four toggle actions, and a SettingsStore filter round-trip.
Full Release suite: 12,392 passed / 4 skipped / 0 failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Root cause (measured live via ACDREAM_PROBE_PARK=1): HeadlessSessionWorldProjection
drove the first-entry conductor unconditionally, including while
HeadlessCollisionNeighborhood's own 3x3 publication plan held a genuinely open
RuntimeCollisionAdmission for the local player's landblock. Every
TrySealCollisionEvaluationAuthority attempt during that window failed
(IsCollisionEvaluationPrefixAdmissible false) and retried forever without
recovering — measured verdict: "seal-refused" repeating with no preceding
[rearm] verdict= line (the operation never even reached the AwaitingCell park).
This is the diagnosis doc's "structural half" mechanism; no evidence of the
"circular HasOldPrefixPlacementDebt" hypothesis was observed, so that shape
was not needed.
Step 1 (enabler): HeadlessStaticStateAudit.ValidateProcessIsolation now takes
sessionCount and only refuses process-global physics probes for
sessionCount > 1 — its own multi-root-attribution rationale never applied to
a single session, and it was blocking the exact probe built to diagnose this
class of stall.
Step 3a (root cause): new IHeadlessCollisionNeighborhood.IsQuiescent gates
ProjectSpawn/ProjectPosition/PumpFirstEntry's conductor-drive calls — the
conductor is never driven while the neighborhood's own publication owns
collision authority for that tick.
Step 4 (defense-in-depth): HeadlessLocalPlayerFrameHost.CanAdvancePlayer now
requires Controller.CanExecuteLiveMovement instead of just a non-null
controller — the headless-only gap that turned the (now-fixed) hydration
stall into a hard crash reaching SuspendObjectUpdate on a dormant controller.
RuntimeLocalPlayerFrameController's three shared entry points gained the same
guard, contract-preserving for the graphical host.
Verified end-to-end against live ACE (jump-probe policy, three runs):
hydration succeeds cleanly (136 entities load vs. 0 before), no seal-refused
spam, no crash from the original bug, graceful logout every time. Full
airborne-transition confirmation is blocked by a separate, newly-discovered,
pre-existing defect filed as #368 (the headless scheduler's
Task.Delay(...).ConfigureAwait(false) tick loop can resume on a different
ThreadPool thread mid collision-generation, tripping
EnsureCollisionMutationThread) — explicitly out of scope here, not mentioned
anywhere in the #365 diagnosis, and unsafe to fix without graphical-host
verification this session was constrained not to perform.
New tests: the real-admission hydration test (fails on the pre-Step-3a tree,
verified by temporarily reverting the three gates and confirming failure,
then restoring), the PumpFirstEntry quiescence-gate test, the
CanAdvancePlayer publication-lifecycle test, the dormant-controller
sabotage tests for RuntimeLocalPlayerFrameController, and the audit
single/multi-session tests. RuntimeLocalPlayerPhysicsPublicationStateTests
is untouched.
Full Release suite: 12,343 passed / 4 skipped / 0 failed (baseline ~12,330/4
plus 11 new tests).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
User-gate round 3 findings (a)-(c):
(a) SpewBox: TopOffset moves from the round-1 60px placeholder to 0 (flush
to the viewport top). SpewBoxController never wired DatFont/Font at all
before this round, so it silently rendered through the 15px debug
BitmapFont fallback; it now resolves retail dat Font 0x40000025
(MaxCharHeight=11px) through a new RetailUiRuntime.Assets accessor —
the smallest font id confirmed in use by any currently-imported retail
LayoutDesc fixture, cross-referenced against every
tests/AcDream.App.Tests/UI/Layout/fixtures/*.json dump and confirmed
against the installed DAT via AcDream.Cli dump-font-atlas. It is also the
chat window's own smallest font (the 0x2100006F floating-window 1/2/3/4
indicator badges), so both selection criteria the brief offered agree.
Both remain best-available approximations, not resolved retail values —
register row AP-178 updated accordingly.
(b)/(c) /help and /help death: round 2 extracted the individual retail
strings byte-exact but never traced ClientCommunicationSystem::DoHelp's
complete print sequence. Byte-swept DoHelp's own range plus the five
Summary-branch functions it calls into (HelpEmote/HelpSquelch/
HelpStatusGroup/HelpTextGroup/HelpAllGroup) against the PDB-paired
acclient.exe. Retail's real shape: bare /help prints exactly TWO scroll
entries (HelpPrefixNote, then the 13-item AvailableHelpListing built from
DoHelp's own literals and each group's Summary_HelpType branch, in exact
source order) — not the acdream-invented cheat sheet BuildHelpText()
built before. Any resolved /help <verb> gets the SAME two-entry shape:
HelpPrefixNote, then ForMoreInformationPrefix concatenated directly onto
the verb's own Detail text (retail's own unsubstituted "<command>"
literal, ported verbatim). ChatCommandRouter.EmitVerbHelp applies this
uniformly to every resolved verb, not just death. An unresolved verb now
shows retail's real "Unknown command" fallback text; that fallback types
0x1A (ClientLocal), which retail routes to the SpewBox exclusively — a
gap ChatVM's UI.Abstractions layer can't yet reach, filed as ISSUES #367
/ register AP-186 rather than left silently unregistered.
Jump-in-air (round 2's open item 1) was root-caused and fixed separately
at a5a7eb4f between rounds — recorded in the campaign ledger.
Debug suite (all projects): 12,329 passed / 4 skipped / 1 failed — the
one failure is issue #351, a pre-existing Debug-only streaming flake
confirmed reproducing identically on the pristine pre-round-3 commit via
git stash, not a regression. Release verification covers every project
reachable without rebuilding AcDream.App: a live client process (PID
15064) held its own Release binaries locked for the session and was not
killed per project policy — AcDream.UI.Abstractions.Tests (867/867, the
layer both /help fixes live in) plus every other non-App-dependent
project, all 0 failed. AcDream.App/AcDream.App.Tests/AcDream.Core.Tests
(the SpewBox fix's layer) are green in Debug only this session.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Round-3 probe evidence pinpointed it: '[jump] ReportJumpRefusal
result=NotGrounded hasCallback=False' — the edge detection, OnWalkable
clearing, and refusal dispatch all worked; the callback was null because
CommitRuntimeOwnedController (the C3c production publication path) writes
_controller directly and never applied _onInterfaceText the way the
internal setter does. Two install paths, one wired. Regression test pins
the commit path.
Runtime tests 1,323/0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Swap ChatWindowController's imported main-chat LayoutDesc from the wrong
0x21000006 (an unrelated layout whose root and 800px resize bar appear
nowhere in the EoR gameplay UI) to retail's ACTUAL main chat window,
0x2100006F (window root 0x10000600, authored 410x100 — confirmed by a
direct DAT dump, found in dats.Local not dats.Portal). Every downstream
compensation that existed only to paper over the wrong import is deleted:
the hand-cropped 490px content width, the dropped 800px resize bar, the
9px transcript patch, the orphan-sibling pruning, the max/min-vs-scrollbar
overlap shift, and the scrollbar top-reclaim. The window now mounts with
RetailWindowChrome.Imported (0x2100006F's own 8 border/corner elements are
its complete chrome) instead of the universal nine-slice wrapper.
LayoutImporter/DatWidgetFactory gain a Type-9 (UIElement_Resizebar) case:
UiResizeGrip decodes retail's exact four-bool BorderLocation algorithm
(0x2A=bottom/0x2B=left/0x2C=right/0x2D=top,
UIElement_Resizebar::StartMouseResizing @0x0046B7E0) into a ResizeEdges
bitmask. A direct DAT dump established the true shape: only 7 of the 8
grip-position ids are Type 9 — the straight top-EDGE strip (0x1000069C) is
a Type-2 Dragbar (move handle), not a Resizebar, because the main window
has no title bar. UiRoot now gives a directly-hit grip's own edges
priority over its generic proximity heuristic, and a directly-hit move
handle the same priority over ambient proximity — so the plain top strip
moves the window while its two corner grips resize it including the Y
axis, and all 4 edges + 4 corners work everywhere else. This also fixes
the reported "no diagonal cursor at corners" (CursorFeedbackController's
existing RetailCursorCatalog cursor ids already matched the DAT exactly;
they just never received a genuine diagonal edge combination) and "cannot
grow in Y from the bottom-right corner" (the old NineSlice+crop mount's
indirection is gone; the Imported mount uses the DAT's real
minH=100/maxH=2000/minW=300/maxW=2000 directly).
The 8 cosmetic "_Locked" border-art twins default hidden (register row
AP-185 — retail's UiLocked-driven art swap between the two skins is not
ported; UiRoot.UiLocked continues to gate the underlying interaction
correctly either way). The 4 chat-window-1..4 indicator buttons import
generically (visible, inert) for CH6b to wire. The two hand-drawn
translucent-black tints on the transcript/input are removed now that
their parent panels draw their own authored background sprites.
Filed #366 (chat window's new-unseen-text indicator 0x1000048C is
swallowed by UiText.ConsumesDatChildren, pre-existing and out of scope).
Corrected the research doc's "all eight grips" claim against the direct
DAT dump. Full Release suite: 12,317 passed / 4 skipped / 0 failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Campaign CH jump-probe headless reproduction quarantined on its first
advance tick: AdvanceBeforeNetwork re-asserts the resolved local entity id
every tick, which the C3c configuration seal treats as a mutation on a
dormant controller. A same-value write is now a no-op; a DIFFERENT id
while sealed still throws. One layer deeper the probe exposed #365: the
headless world never hydrates (entities stay 0, the movement controller
never publishes), so headless bots cannot move at head — filed with the
full evidence chain. HeadlessDiagnosticWriter.Failure now emits the full
exception detail (type-only cost a whole diagnosis round-trip).
Runtime tests 1,322/0, Headless tests 89/0.
Co-Authored-By: Claude Opus 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>
Same staleness class as the Campaign P paragraph: the tracker (register
AP-1/AP-145/AP-22 retirements, addb5657 closure commit, the closed-campaign
memory crib) had it closed since 2026-08-07.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Wrong-LayoutDesc root cause (0x21000006 vs retail 0x2100006F), resident
floating windows toggled by keybind, 8 authored resize grips, global
opacity options 0x10000080/81.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The user tested Campaign CH's CODE-COMPLETE build live and reported ten
defects (docs/plans/2026-08-09-chat-parity-campaign.md, "User gate —
round 1"). Items A-G are fixed here; the remaining three (extra chat
windows on 1/2/3/4, resize working in only one corner, transparency/
artifacts) are out of scope for a fix and filed as slice CH6.
A. Jump-in-air refusal never fired live: the jump block only ever
evaluated input.Jump inside the grounded-charge or already-charging
branches. PlayerMovementController now detects the press RISING EDGE
while airborne and reports WeenieError.NotGrounded once per press,
leaving the grounded charge/fire path untouched.
B. ChatVM's invented "[System] " prefix is dropped — retail prints
system text bare. [Popup] is unchanged (AP-175).
C. SpewBoxController's color is now the user-pinned exact value
(1, 1, 0.247, 1), the same bright yellow as an incoming Tell.
Register row AP-178 updated: color CLOSES, size/position/font stay
open per the user's live report that they still differ.
D. Closes#329: PortalTunnelPresentation now emits the portal wait cue
unconditionally on every rotation-segment boundary, matching
gmSmartBoxUI::UseTime's decompiled else-arm exactly instead of gating
on a 5-second hold local transits never reached. PortalWaitNotice
Controller now renders it in the same pinned yellow as item C.
Register row AP-150 retired.
E. Closes#362: new ClientCommandResponses.cs parses and renders the
four previously-unhandled inbound GameEvents (ChannelIndex,
ChannelList, AvailableHouses, AllegianceInfoResponse), each ported
line-for-line from the named-retail decomp's inbound handlers.
Register row TS-70 retired.
F. ChatWindowController.WrapText now splits on embedded '\n'/'\r\n'
first, then word-wraps each segment independently — server text like
/help's reply no longer collapses onto one line.
G. The chat input field's right edge no longer holds a fixed absolute
pixel position across a window resize; Bind now upgrades it to
retail edge-mode 1 (UiLayoutPolicy) or the AnchorEdges.Right stretch
fallback so it tracks the window's client width instead of
overflowing past a narrower resize.
Full Release suite: 12,247 passed / 4 skipped / 0 failed (baseline
12,221/4/0 + 26 new tests across items A, E, F, G).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The composition scope's completion contract threw 'unpublished
resources: spew box' on every graphical launch with the retail UI:
scope.Acquire's returned lease was discarded, so it could never be
Transfer()ed alongside its siblings. Suite missed it because the
composition tests run with RetainedUi absent, so the acquire block
never executed. Ownership after transfer matches the wait-notice
no-tunnel arm: the retained-UI root's teardown reclaims the element
tree.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CH5: flip the campaign plan's status header from ACTIVE to CODE-COMPLETE,
correct the CH4 ledger row's suite count to its final 12,221 (was showing
the pre-review-fix 12,190) and fill in the CH5 row, and add a closeout
paragraph for the previously-undocumented 5d247d55 re-review round.
Register sweep found one drift: the TS section header claimed 42 active
rows against an actual recount of 40 (TS-66 is retired/struck-through and
was miscounted as active) — corrected. AP/AD/IA/UN section counts,
AP-175..183, AP-176 retirement, and UN-9's deletion all verified
consistent, no other changes. Cross-referenced issues #359-#363 to the
campaign doc. Extended CLAUDE.md's Current-state Campaign-status sentence
to record Campaign CH's CODE-COMPLETE status and carried tail. Added a
Campaign CH entry (plus a missing Campaign A entry) to the roadmap's
shipped-campaign summary block, matching the Campaign V/N/P paragraph
format.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Should-fix 1: RetailDialogFactory.CloseDialog's queued branch removed the
active DialogInfo, ran DialogDone (whose callback can synchronously open a
new dialog under the SAME queue key — the two-stage house-abandon
confirmation does exactly this), then called OpenNextDialog, which did an
unconditional Dictionary.Add on a key the reentrant dialog had already
re-occupied. Retail's HashTable::add tolerates the duplicate; Dictionary
throws. OpenNextDialog now returns early when the queue key is already
active — the reentrant dialog's own eventual close drains the queue.
Should-fix 2: @join/@leave wrote the local RuntimeCharacterOptionsState bit
before sending, but the Settings Chat toggles reached a second binding
(SendSingleCharacterOption) that only sent the wire message, leaving the
Turbine membership gate stale until the next PlayerDescription.
LiveSessionRuntimeFactory.CreateCommandBindings now has one shared local
function for both entrances.
Should-fix 3: corrected TS-68/#360 wording again — retail's DoAllegiance
dispatcher table EXECUTES boot/ban/officer/title/motd/name/lock/house/
chat/broadcast locally through their own handlers; acdream shows the
unrecognized-subcommand refusal for all nine pending the #360 port. What
matches retail is the ownership rule (the verb never reaches
DoChannelCommand/the server), not the subcommand behavior itself. Removed
the inaccurate "matching retail, not merely harmless" / "now matches
this" claims from both the register row and the issue.
Nits: corrected the HouseAbandonDialogCallback_First citation (0x00580E1A
is DoHouse's load site for the callback pointer, not the function entry —
the entry is 0x00580240, with the stage-2 confirmation string built at
0x005802D8) in both ClientCommandController.cs and the mirrored test
comment; added an InlineData case pinning "@clist allegiance" to
RequestChannelList(0x02000000); converted RetailClientCommandCatalog.
KnownVerbs from a plain array to a FrozenSet<string> with
StringComparer.OrdinalIgnoreCase, matching the file's other lookup tables.
Suite: 12,221 passed / 4 skipped / 0 failed (Release), up from CH4's
12,216/4/0 — net +5 tests, no removals.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Blocker 1: an unrecognized "@allegiance <sub>" subcommand escaped
TryMatchAllegiance (which only claimed "info"/"hometown") and fell through
the unregistered-tag channel fallback, broadcasting the raw subcommand
text to the Allegiance chat channel (0x02000000). Retail's own
DoAllegiance never reaches DoChannelCommand for an unrecognized
subcommand — it claims the whole verb and prints its own client-local
refusal. TryMatchAllegiance now claims "allegiance"/"all" unconditionally
and shows retail's "Please see @help Allegiance..." text; ChatCommandRouter
also gained a blanket RetailClientCommandCatalog.KnownVerbs ownership
guard in TryDispatchChannelFallback as defense in depth.
Blocker 2: "@house abandon" sent 0x021F immediately with no confirmation.
Retail runs a real two-stage dialog before Event_AbandonHouse(); ported
both verbatim strings and chained two ShowConfirmation calls.
Should-fixes: a bare unregistered tag with no text now passes through
silently instead of showing a refusal that belongs to a different retail
function; @join/@leave update RuntimeCharacterOptionsState locally (new
SetOptionBit) before the wire push so the Turbine membership gate stops
refusing a just-joined room; @permit accepts multi-word names; @clist/
@on/@off validate shape only and raise WeenieError 0x422 for an unknown
tag; @mr/@pr help text is now the verbatim retail strings; corrected
issue #360, register row TS-68, the campaign doc's B.7 note, and a stale
RetailChannelTagTable comment; filed issue #363 + register row AP-183 for
the deferred error-typing debt.
Nits: fixed TryMatchHouse's stale doc comment, the AP-182/@title "stores
the value" comments (the binding is a no-op), IsUnregisteredFallbackTag's
olthoi false-positive, added /g and /rp binding-level conformance pins,
made @index ignore extra arguments, and noted the six removed invented
verbs in ISSUES.md.
Suite: 12,216 passed / 4 skipped / 0 failed (Release), up from CH4's
12,190/4/0 — net +26 tests, no removals.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Brings acdream's / and @ command parsing to parity with the complete
retail registry (130 registered verbs + 22 unregistered GetChannelID
fallback tags = 152 client-parsed verbs), per
docs/research/2026-08-09-chat-retail-command-registry.md.
Parser semantics (retail OnChatCommand/DoCommand):
- : and ; rewrite to "@emote <rest>" before dispatch.
- Verb trailing-comma trim ("@f, hi" == "@f hi") applied at every
verb-lookup site in the catalog and the parser.
- @tell/aliases split the target on the FIRST COMMA, not the first
whitespace token, so multi-word names work ("@tell Aunt Agatha, hi").
- The 22 unregistered GM/faction channel tags (admin, sentinel,
celestialhand, ...) now broadcast for real via a new
RetailChannelTagTable + SendRawChannelCmd bypass, reusing the existing
BuildChatChannel wire builder.
Binding corrections:
- /g, /group, /party -> Fellowship (0x800), not General.
- /rp -> reply alias (retail's own help text confirms "@r or @rp"), not
Roleplay; /role (an acdream invention) deleted.
- /allegiance, /all -> the allegiance management command
(RetailClientCommandCatalog), not a channel verb.
- /house no longer swallows unrecognized subcommands with a local usage
error; they now correctly fall through to ACE.
- @mr/@pr pinned as permanently non-executable (retail registers them
with a null function pointer).
New verbs with real local execution: endurance, speaker, title (silent,
AP-182), chat, notell, join, leave, permit, hslist, index, clist, on,
off, alh/ah (+ "@allegiance hometown"/"ho"), "@allegiance info",
"@house abandon"; a missing-alias sweep across pkl/hou/message_types/
msgtypes/msg_types/rt/send/whisper/w/vassal/covassal/co-vassals/c/
fellows/group/party/guild/gu/cg/ct/clfg/crp/soc/o; the non-retail
inventions gen/cv/lookingforgroup/tr/role/h are deleted. New Core.Net
wire builders (IndexChannels, ListChannels, AddChannel, RemoveChannel,
RecallAllegianceHometown, AllegianceInfoRequest, ListAvailableHouses,
AddPlayerPermission, RemovePlayerPermission, AbandonHouse) are all
parameterless or single-field payloads cross-checked against ACE's
GameAction readers, not guessed.
Deferred (filed as #360/#361/#362, register rows TS-68/TS-69/TS-70):
the ~22 remaining allegiance/house subcommands + standalone @motd
(largest single item, needs its own slice per the doc), the three
still-inert pure-local commands (day/log/render), and the inbound
GameEvent responses for the new outbound requests. All correctly fall
through to ACE server-passthrough rather than being silently swallowed
or faking success.
RetailCommandRegistryConformanceTests pins the complete 152-verb
registry against production: every verb resolves through exactly one
production surface if Implemented, through none if HelpOnly/
ServerPassthrough, and two reverse-direction tests fail the build if
RetailClientCommandCatalog or ChatInputParser ever claims a verb
outside this registry again. Final tally: 138 Implemented / 5
ServerPassthrough / 9 HelpOnly = 152.
Release suite: 12,190 passed / 4 skipped / 0 failed (up from CH3's
11,964/4/0).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Applies the Opus review of Campaign CH slice CH3 (614a1e05):
- B1: UN-9 was a phantom divergence — ACE's CharacterOptions1.cs:47
OR-sum is 0x50C4A54A (its own comment confirms 1355064650), identical
to acdream's literal. The wrong 0x50C48D4A existed only in the research
doc. Row deleted, register §5 reverted to 4 rows, research doc corrected
with dated notes.
- S1/S4: AllegianceBroadcast (0x02000000) is a server-echoing channel —
ACE's GameActionChatChannel handler includes the sender in its real-name
Allegiance.Members broadcast (retail's DoAllegianceBroadcast has no
AddTextToScroll), so the client must skip its local optimistic echo, not
keep it. ChatChannelInfo.Legacy.IsSelfEchoChannel() now returns true for
it; RouteLegacyChannel's comment corrected; Turbine.IsSelfEchoChannel()'s
backwards comment rewritten truthfully.
- S3: retail's /a stays on the legacy AllegianceBroadcast bitflag until
StartupTurbineChatSystem successfully starts Turbine chat — "never
started" (TurbineChatState.Enabled == false) now falls back to legacy in
both LiveSessionCommandRouter.RouteChat and
DirectGameRuntimeCommandAdapter.TrySendChannel, while "enabled but no
allegiance room" still correctly refuses locally.
- S5: added a LiveSessionEventRouter test proving the Options.Replace ->
OnCharacterOptionsChanged seeding order, and RuntimeSettingsTargets /
GameWindowLiveSessionOwnershipTests tests proving the concrete
ICommandBus.Publish wiring and the single LiveSessionCommandSurface
construction site.
- S6: AP-181 rewritten to name both of retail's omitted pre-send checks
(IsMessageSafe silent-drop, then IsMessageSpam) and stop misattributing
either to RouteLegacyChannel, which has no such gates.
- N1-N7: CharacterOptionId moved below SocialActions so its doc comment
re-attaches; TurbineChatMembershipGate reuses TurbineChatDisplayNames
instead of a duplicate table; the gate-to-refusal-text mapping is now
shared via TurbineChatMembershipGate.ResolveRefusalText instead of
duplicated in both hosts; ChatSettings.Default now matches ACE's real
CharacterOptions2.Default (Roleplay/Society start off); a doc-comment
clarifies only the five Hear toggles are server-backed; the register's
§3 header recounted 129 -> 128.
Suite: 11,964 passed / 4 skipped / 0 failed (baseline 11,957/4/0 + 7 new
tests). Campaign ledger CH3 review column updated to APPROVE-WITH-FIXES.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The test feeds a far-tier factory returning Near payload on purpose to
prove the strip safety net. LandblockStreamer.HandleJob is documented
"fail loud in Debug builds and strip in Release": the Debug.Assert fires
on exactly that input, the VSTest host translates it into a thrown
DebugAssertException, and the worker catch folds it into a Failed
completion — deterministic on every Debug run since the test and the
tripwire landed in the same commit (090b0354). Release compiles the
assert out ([Conditional("DEBUG")]), so the strip runs and the test
passed there, which is why the Release gate never saw it.
The test now pins BOTH halves of the config-divergent contract via
"#if DEBUG": Debug expects the loud Failed carrying the assert text,
Release keeps the strip assertions. Production code unchanged.
LandblockBuildOriginTests 11/11 in Debug AND Release.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Follow-up to 614a1e05 — the ledger row's commit reference couldn't be
known until after that commit landed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ports retail's SendTurbineChat (@0x0057db10) local pre-send membership gate
so Roleplay/Society/Olthoi stop silently swallowing outbound chat: a new
TurbineChatMembershipGate checks Turbine availability and the player's own
Hear*Chat option before sending, raising "Turbine chat is not available."
or the 0x0551 YouAreNotListeningTo_Channel refusal through the CH2 AddText
chokepoint instead. Wired into both the graphical (LiveSessionCommandRouter)
and headless (DirectGameRuntimeCommandAdapter) send paths so they can't
diverge. Retracts the 26-day-old false "ACE doesn't run a TurbineChat
server" claim from ISSUES.md, the roadmap, and project_chat_pipeline.md —
ACE's TurbineChat implementation is complete and on by default; the real
bug was treating Hear*Chat as a display filter instead of room membership.
Also: implements SetSingleCharacterOption (0x0005), the only wire message
that actually joins/leaves a Turbine room, and wires the five Settings Chat
toggles to it (publish on Save, changed bits only) plus seeds ChatSettings
from the server's own CharacterOptions2 on every PlayerDescription. Fixes
the legacy-channel double-print (Fellow/Vassals/Patron/Monarch/CoVassals
skip the local echo now that ChatChannelInfo.IsSelfEchoChannel is finally
consulted). Routes /a to Turbine unconditionally (retail's @a never falls
back to the legacy bitflag) and adds /ab for the legacy AllegianceBroadcast
verb retail actually has. Surfaces a nonzero TurbineChat ack HResult instead
of discarding it silently. Deletes the malformed, callerless SetCharacterOptions
(0x01A1) and AddChannel/RemoveChannel (0x0145/0x0146) builders.
Files every AC-specific algorithm change cites the named retail decomp
(SendTurbineChat 0x0057db10, StartupTurbineChatSystem 0x0057EFB0,
GameActionSetSingleCharacterOption) plus ACE/holtburger cross-checks.
Register rows AP-181 (no client-side spam throttle) and UN-9 (an
incidentally-discovered CharacterOptions1.Default literal mismatch, not
investigated further) filed per the divergence-register rule.
11,957 passed / 4 skipped / 0 failed (full Release suite, up from the
11,916/4/0 baseline).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Applies the seven NITs from the CH2 re-review (verdict APPROVE-WITH-FIXES,
following the REJECT->rework at e0e78883):
1. SpewBoxController's centered Left was captured once via
AnchorEdges.Top and replayed forever on resize (UiElement.ApplyAnchor's
Left/Right-both-false branch pins a fixed margin). Anchors is now
AnchorEdges.None and Tick recomputes Left every frame against the
current root width.
2. OneLine=false was defaulting to UiText's bottom-pinned transcript flow
(VerticalJustify honored only via ConfigureDatState, which this
synthesized element never calls). Added UiText.HonorVerticalJustification
so a non-DAT controller can opt the scrollable path into
VerticalJustify without a full LayoutDesc binding; SpewBoxController
sets VerticalJustify=Top so lines flow from the top of the 450x72 box,
matching newest-at-top insert semantics. Noted as invented-pending-
measurement in AP-178's row (no new row).
3. Documented the deliberate inversion of UiText.LinesProvider's
oldest-first contract in SpewBoxController.Tick (SpewBoxVM.Lines feeds
newest-first, which is correct specifically because the box is now
top-aligned) and added a test pinning the rendered order (newer message
is the topmost line), driving root.Tick.
4. Fixed the stale "retail's code default, 1" comment in
SpewBoxControllerTests — MaxConcurrentItems is the shipped LayoutDesc's
AUTHORED value, 4.
5. Added the matching unmapped-id diagnostics line to
LiveSessionRuntimeFactory's ShowWeenieError sink, matching the pattern
GameEventWiring's WeenieError/WeenieErrorWithString handlers already
use.
6. Corrected the "EXHAUSTIVE Portal sweep found ZERO" overclaim in
SpewBoxLayoutDumpDiagnostic: the loop's id source was DatCollection's
top-level aggregate GetAllIdsOfType<LayoutDesc>(), not dats.Portal's
own (which reports a count of ZERO for this type), so querying those
ids against dats.Portal.TryGet established nothing about Portal either
way. Corrected the same overclaim echoed in SpewBoxState's
MaxConcurrentItems doc comment and in AP-178's register text (both the
table row and the section-header history line). What's actually
established: dats.Local hosts the SpewBox layout at 0x21000011; whether
Portal also carries a copy remains unestablished.
7. Added a test exercising the full ShowWeenieError -> AddText -> SpewBox
path for id 0x0561 (the 50-friends-cap refusal) in
LiveSessionCommandRouterTests, mirroring LiveSessionRuntimeFactory's
ShowWeenieError closure exactly since every other LiveSessionRuntimeFactory
test in this tree is a source-text conformance grep, not an
instantiation.
Ledger: CH2 ledger row's review column now reads REJECT -> reworked
e0e78883 -> re-review APPROVE-WITH-FIXES -> nits (this commit); Status
header flips CH2 to code-complete/closed pending the user gate, CH3 next.
Build green; touched-project tests green (19/19 new/changed,
4351/3354 App.Tests unaffected pass); full Release suite 11,916 passed /
4 skipped / 0 failed (baseline 11,914/4/0 plus the two new tests this
commit adds).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reworks Campaign CH slice CH2 per the REJECT-review findings doc
(docs/research/2026-08-09-ch2-review-findings.md).
BLOCKER 1 — SpewBoxController never rendered a line and leaked its
pending queue. LinesProvider only ran through UiText.OnDraw, which
gates on Visible — and the box started invisible, so the provider (the
sole caller of SpewBoxState.Tick) never ran. Gave the controller an
explicit per-frame Tick(now) driven by UiRoot's global-message-3
broadcast (a zero-size GlobalTimeSink child, the same pattern
VendorUiController.DragOverGlobalTimeSink already uses), matching
retail's gmSpewBoxUI::Update. LinesProvider now only returns the
cache. Tests rewritten to drive root.Tick(...) instead of calling the
provider directly, plus new coverage for visibility-without-a-draw,
queue-drain-without-a-draw, and bounded-queue-across-many-ticks.
BLOCKER 2 — re-derived the HandleFailureEvent routing table from the
PDB-paired binary instead of the pseudo-C's ~33-char string previews.
tools/pdb-extract/sweep_weenie_strings.py sweeps every push imm32 in
VA 0x571990-0x575480, dereferences into .rdata/.data, and decodes the
full UTF-16LE literal. Added the 5 ids dispatched via else-if (missed
by case-label enumeration), resolved 0x4F8 (previously excluded),
fixed 18 wrong strings (16 the review flagged + 2 more — 0x4E9 and
0x518 — an automated diff between every swept literal and the landed
table found). Every changed row cross-checked against ACE's
WeenieError/WeenieErrorWithString enum doc comments; both oracles
agreed on every row, including a case where the review's own proposed
text for the new 0x4E8 row was itself wrong (it was 0x4E9's text) —
corrected via the else-if block's own instruction address plus the ACE
cross-check. Pinned table count: 344 (338 + 5 + 0x4F8).
SHOULD-FIX 1 — RuntimeCommunicationState.ResetSpewBox was dead code;
folded into the ChatIdentity generation-reset stage (same lifetime
boundary), with a reset assertion added to the existing populated-reset
test.
SHOULD-FIX 2 — AddText trimmed only the trailing end and invented an
empty-string early return; retail's AddTextToScroll trims both ends
(trim(&str, 1, 1, ws)) and has no empty guard. Both retired.
SHOULD-FIX 3 — ShowWeenieError bypassed the AddText chokepoint via
ChatLog.OnWeenieError (hardcoded LogTextType 0x00); routed through
Communication.AddText(Resolve(code, param)) instead, and
ChatLog.OnWeenieError is deleted — GameEventWiring's legacy no-router
fallback now resolves + calls OnSystemMessage directly.
SHOULD-FIX 4 — retail's HandleFailureEvent switch has no default case;
an unmapped id now resolves to a null Text (silence toward the
player) instead of the invented "WeenieError 0xNNNN" hex fallback,
with a diagnostics-only console log line for the id.
NITs — AP-TBD placeholders corrected to their real register rows
(AP-178, not the unrelated AP-177 lifetime row); filed AP-180 for the
windowId dual-destination gap and corrected three stale "lands with
CH2" comments; extended SpewBoxLayoutDumpDiagnostic from dats.Portal
to dats.Local and found the SpewBox element for real — LayoutDesc
0x21000011, element 0x10000048, size 450x72, MaxConcurrentItems
(ListBox property 0x10000028) = 4, not retail's code default of 1.
AP-178 narrowed accordingly; SpewBoxState.MaxConcurrentItems and
SpewBoxController's extent/anchor/OneLine are now authored rather than
placeholder (absolute screen position and colour remain open); fixed
the "19 ids... lists 18" miscount by retiring the stale paragraph in
the class doc rewrite; aligned the UseDone handler's silent-status
check with the other two WeenieError handlers.
Full Release suite: 11,914 passed / 4 skipped / 0 failed (build 0
errors).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Slice CH2 (retail SpewBox interface text) landed at 77c8296e; records
its commit + suite counts in the campaign ledger. Review and user gate
still pending.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Retail routes on-screen refusals ("You can't jump while in the air",
"You are too encumbered to carry that!") through a SEPARATE transient
screen surface (gmSpewBoxUI, ClientSystem::AddTextToScroll @0x00563C50)
that never touches the chat scroll — type 0x1A is exactly the bit every
ChatInterface window's default filter excludes
(ChatInterface::ChatInterface @0x004F4550). acdream had no such split:
every WeenieError rendered in chat at a single stand-in LogTextType
0x00 (CH1-era approximation, register AP-176), and locally-detected
jump refusals were silently discarded.
This slice ports the full mechanism per
docs/research/2026-08-09-chat-retail-interface-text.md:
CORE (AcDream.Core/Chat):
- WeenieErrorMessages.Resolve now returns (text, RetailLogTextType) from
a 338-row transcription of ClientCommunicationSystem::HandleFailureEvent
@0x00571990 (Appendix A's 339 cases minus one, 0x4F8, deliberately
excluded — its case body is a tangled decompiler artifact, not
resolvable with confidence). Spot-checked ~20 rows directly against
the raw decomp (case 0x2b/0x36/0x3a/0x4e/0x4ec/0x4f3/0x4f4 and the
jump family), beyond the ~10 the brief asked for, because the first
pass surfaced two transcription classes the research doc's markdown
silently ate: (1) 7 ids marked "shared string global" resolved by
reading the case bodies directly (0x24/0x48/0x49 reuse the jump-
refusal globals; 0x4DE/0x4DF/0x55A/0x55E are pure param passthrough);
(2) 19 "arg3 + literal" CONCATENATION ids whose leading space (and
therefore their %s marker) the markdown table's cell-trimming ate —
fixed by re-reading each case body, several requiring a SECOND
non-truncated data_XXXXXXXX dump elsewhere in the same oracle file to
recover text the ~33-char inline preview cut off. One retail typo is
preserved verbatim: 0x4F4's second placeholder is literal "$s", not
"%s" — only the first substitutes.
- ClientTextRefusals: the 11 process-lifetime string globals, all
byte-recovered from the PDB-paired C:\Users\erikn\Downloads\acclient.exe
(MATCH verified via check_exe_pdb.py) via raw UTF-16LE prefix search —
5 were truncated in the research doc's own transcription and all 5
turned out to end "...combat mode"/"...this position", not the
shorter "...combat" a truncated read would suggest.
- SpewBoxState: the gmSpewBoxUI pending/visible queue port (insert-at-0,
dedupe-against-index-0-only, MaxConcurrentItems overflow, per-entry
expiry, one-frame enqueue/drain decoupling). Placed in Core (not
Runtime as the brief's default) because AcDream.UI.Abstractions
references Core but not Runtime, and SpewBoxVM needs to wrap it
directly — the same constraint ChatVM already satisfies against
ChatLog.
- Folded the 4-entry WeenieErrorText.cs into the full table; deleted it.
RUNTIME (AcDream.Runtime):
- RuntimeCommunicationState.AddText(text, type, windowId): the
AddTextToScroll chokepoint. type == ClientLocal -> SpewBox only, never
chat; everything else -> the existing transcript, tagged with type.
- GameEventWiring gains an `onInterfaceText` delegate hole (Core.Net
cannot reference Runtime, so this follows the file's own established
pattern for every other Runtime-owned sink). Rewires 0x028A/0x028B/
UseDone through the full table + router; fixes 0x02EB
CommunicationTransientString's routing type from a CH1-era 0x00
guess to retail's hardcoded ClientLocal (Handle_Communication__
TransientString @0x0057D460).
- LiveSessionEventRouter's 0xF7E0 ServerMessage handler now routes
through AddText with the wire chatType verbatim instead of always
writing ChatLog directly.
- PlayerMovementController gains OnInterfaceText, applied by
RuntimeLocalPlayerMovementState to every controller it installs.
Reports ChargeJump/jump refusals exactly as ClientCombatSystem::
CommenceJump @0x0056AF90 / DoJump @0x0056B110 do — confirmed via
their compiled dispatch that ONLY 0x24/0x48/0x49 produce text;
0x47 (GeneralMovementFailure, fully-constrained/no-stamina) and any
other code are retail-SILENT (DoJump's jump table has exactly 4 real
targets), which contradicts this task's brief ("0x47 -> the
constrained/stamina row per §4.2") — the brief's reading of §4.2
described what jump_is_allowed COMPUTES, not what CommenceJump/DoJump
DISPLAY for it. Implemented the decomp-verified silent behavior.
APP (AcDream.App / AcDream.UI.Abstractions):
- The 5 composition sites that already used RetailLogTextType.ClientLocal
now call Communication.AddText instead of Chat.OnSystemMessage
directly, so they reach the SpewBox instead of the transcript.
- SpewBoxVM (UI.Abstractions) + SpewBoxController (App), modeled
directly on PortalWaitNoticeController. Position/font/colour/
MaxConcurrentItems are placeholders: SpewBoxLayoutDumpDiagnostic
exhaustively swept the installed client_portal.dat's entire LayoutDesc
id range (0x21000000-0x21000075, 101/118 ids populated, sanity-checked
against 3 known ids) and found ZERO elements of class 0x10000016 —
gmSpewBoxUI is mounted from C++ code, not any authored LayoutDesc, so
the dump cannot recover these values.
REGISTER: AP-176 retired (its WeenieError half is now the full table
port); its OnCombatLine half was never in this slice's scope and is
split out to AP-179 so that divergence keeps a row. AP-177 (invented
line lifetime) and AP-178 (invented position/font/colour/max-items)
filed for the presentation placeholders above. AP-175 (PopUpString ->
chat instead of modal) is untouched, not duplicated.
Suite: 11,890 passed / 4 skipped / 0 failed (was 11,835/4/0; +55 net
new tests, 0 regressions).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Applies the Opus review findings on CH1 (172c6f9a), the exact retail chat
color table. Two blockers plus should-fixes/nits, one commit:
BLOCKER 1 — LegacyChannelChatType.Resolve's channel-bit table was wrong.
Binary Ninja renders retail's `neg esi; sbb esi, esi` idiom (a branchless
select between Channel 0x08 and Channel_Send 0x09) as the trivial pseudo-C
`esi - esi` (always 0), hiding the real values. Corrected by decoding the
raw bytes at the PDB-paired binary: HEAR sbb site VA 0x00570F0A (mask -6 ->
0x08), SEND sbb site VA 0x00570D4F (mask -5 -> 0x09). The generic
admin/audit/sentinel catch-all is Channel/Channel_Send, NOT Abuse (0x0E) —
Abuse is retail's ONLY 0x0E producer (bit 0x0001). The unnamed
FellowBroadcast bit (0x4000000) is hear=Channel(0x08)/send=Fellowship(0x13),
not a flat 0x13. ACE's PDB-sourced Channel enum corroborates. Introduces
`RetailLogTextType`, the 34-value named enum for the wire LogTextType space
(values only, no color — Core stays presentation-free).
BLOCKER 2 — three ChatLog.OnSystemMessage sinks (ChatVM.ShowSystemMessage,
LiveSessionRuntimeFactory's ShowSystemMessage delegate,
HeadlessGameplayOperations.DisplayMessage) were typing ALL
ClientCommandController output 0x1A (bright red), including informational
command output (@version, /loc, friends list, usage lines). Retail types
the great majority of that output 0x00 Default (green) and reserves 0x1A
for genuine refusals/errors. Reverted to 0x00 with a comment noting the
refusal-vs-info split lands with CH2's SpewBox producer rewiring. The five
App composition sites that pass 0x1A for actual refusal text
(InteractionRetainedUiComposition, SessionPlayerComposition) were already
correct and are untouched (aside from converting the literal to the new
enum).
Also: AP-176 divergence-register row for OnWeenieError/OnCombatLine's
single-type approximation of retail's per-code/per-message dispatch; a
carry-forward test for the out-of-range LogTextType color fallback in
ChatWindowController; decomp-confirmed anchors replacing ACE-inferred
citations in CombatChatTranslator and ChatLog.OnPlayerKilled; required
(non-optional) logTextType parameters on OnLocalSpeech/OnTellReceived/
OnCombatLine/OnSelfSent since no production caller relied on a default;
LegacyChannelChatType.Resolve's parameter renamed channelBit -> channelId
with a doc note on multi-bit ids; corrections to the color-table research
doc's §3.3 wire tables; and issue #359 for the pre-existing (not
CH1-introduced) 0x019E PlayerKilled participant-suppression gap retail has
and acdream lacks.
dotnet build clean; full Release suite 11,835 passed / 4 skipped / 0 failed
(11,839 total), up from the CH1 baseline of 11,833/4/0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Commits the command-registry, interface-text (SpewBox), and
side-channels-vs-ACE research docs (R3 color-table landed with CH1).
Corrects the CH1 ledger SHA the implementer recorded pre-amend.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Retail colors chat lines by the 34-value wire LogTextType (ACE's
ChatMessageType), NOT by acdream's synthetic 9-value ChatKind. The old
ChatWindowController.RetailChatColor(ChatKind) collapsed distinct retail
colors onto one bucket per ChatKind — e.g. every Channel line rendered
colorLightBlue (Magic's slot) when retail's actual palette spans five
different colors across the Turbine rooms and legacy allegiance family.
Ports ChatInterface::BuildChatColorLookupTable @0x004F31C0 verbatim
(RetailChatColorTable, all 34 RGBA floats read from the PDB-paired
binary's .data section) and threads a new ChatEntry.LogTextType field
through every ingestion site to the correct retail wire value:
HearSpeech/Tell pass the wire chatType through verbatim; Emote/SoulEmote
hard-code 0x0C; the Tell self-echo hard-codes 0x04; legacy ChatChannel
broadcasts derive their type from the channel bit via the new
LegacyChannelChatType helper (ported from the decompiled
Handle_Communication__ChannelBroadcast dispatch, hear vs. own-send);
TurbineChat rooms map through TurbineChatDisplayNames.LogTextType;
CombatChatTranslator's hit/miss/evade lines map to ACE's CombatSelf/
CombatEnemy per Player_Combat.cs; kill/death lines use retail's
decompiled 0x00 Default (not a combat color). ChatWindowController's
transcript now folds LogTextType through RetailChatColorTable with
retail's exact "out-of-range keeps the previous line's color" carry
rule; ChatPanel's combat highlighting sources the same table.
Corrects HearSpeech.cs's doc-comment ChatType legend (4 of 6 entries
were wrong). Adds register row AP-175 for the pre-existing (unchanged)
Popup-renders-in-chat divergence and updates AP-39's stale per-ChatKind
description. Narrows ISSUES #139 — its chat-colors half is done.
Retail renders no chat timestamp prefix path exists in acdream today,
so the "timestamp is always colorGrey 0x0C" rule has nothing to attach
to; noted here per the research doc rather than left silent.
Research: docs/research/2026-08-09-chat-retail-color-table.md
Full Release suite: 11,833 passed / 4 skipped / 0 failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
First track of the alpha-release program. Four deliverables: exact
BuildChatColorLookupTable colors, working side channels vs ACE, retail's
on-screen interface text, complete / and @ command registry. Model split
per user direction: Opus research, Fable planning, Sonnet implementation,
dual-lens Opus review per slice. Slices finalize when the four research
lanes land.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The plan doc and ISSUES.md already carried the closures; CLAUDE.md's
Current-state paragraph still listed Campaign P as active with #268,
#269, TS-8, and matrix rows open. Also records Campaign A's
code-complete state and its open tail (#358).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Deferred by user direction. The mute mechanism (2cf94dbc) is fine; the
chord never fires: 101 [input] actions logged in the session, zero for
AcdreamToggleAudioMute. Hypotheses ranked in the issue — merged-binding
absence (the suspicious stable '152 bindings' count), dispatcher modifier
matching, retained-UI Ctrl-chord consumption.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two items from listening-gate round 2.
Mute: AcdreamToggleAudioMute (default Ctrl+M; bare M is selection) flips
OpenAlAudioEngine.Muted, implemented as the AL LISTENER gain - unused
since A2 moved all mixing to the CPU, so it is a free master switch that
silences already-playing voices instantly and restores them exactly,
without touching the retail mixing math, the -50 dB allocation cutoff, or
any persisted volume setting. Rebindable like every other action; console
line confirms each flip.
Inn chatter: the user hears talk-and-laughter ambience in retail inns and
not in acdream. Three installed-dat scans (pinned as conformance tests in
EnvCellSoundEmitterInventoryTests) prove the mechanism is NOT client
data: no interior static in the town landblock carries an ambient-slot
sound table, no Setup among all 5,935 in the portal dat references one,
and yet 23 sound tables carrying ONLY Ambient1..8 slots exist - pure
soundscape banks with nothing client-side pointing at them. They are
wire-bound: the server attaches one to an emitter object via
CreateObject's sound-table field and fires the slots over 0xF750 - ACE
implements exactly this (EmoteType.Sound heartbeat emotes ->
GameMessageSound broadcast). Our 0xF750 receiver (slice A3) is live and
now instrumented (ACDREAM_PROBE_SOUND_WIRE=1, via the new
AudioDiagnostics owner per Code Structure Rule 5, with per-event drop
reasons in AudioHookSink.PlayServerSound). A probed session against the
local ACE received ZERO 0xF750 events across a town walkabout: the
silence is server world-content (no emitters configured/firing), not a
client drop. The first scan's assertion originally encoded the
emitter-object hypothesis; the data refuted it, and the test now pins the
negative so the conclusion cannot silently rot.
Full Release suite green (the one failure during development was the
hypothesis-pinning assertion, corrected to pin the finding).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two user findings from the Campaign A listening session.
1. The portal tunnel's in-flight sound was silent while its enter/exit
cues played. The tunnel's authored SoundTweakedHook drained into the
world 3-D path at its synthetic owner's origin (0,0,0) — after A2 that
dies twice: the listener is usually beyond the -50 dB no-allocate radius,
and the world pool is suspended for the whole transit hold. The cues the
user COULD hear were on the interface bus, which has neither problem, and
retail's tunnel is gmSmartBoxUI — UI-owned — so that bus is also the
faithful route. UiPresentationHookSink now wraps the shared router for
the tunnel: sound-bearing hooks go from-centre through the interface bus
(AudioHookSink.OnUiHook); every other hook kind still reaches the
particle/lighting/translucency sinks unchanged.
2. Ambience cut dead inside houses; retail keeps the outdoor soundscape
in sky-lit interiors. This is TS-66, now retired: the ambient listener
source resolves the per-cell CEnvCell.seen_outside bit through the
physics cache (the same #107 field AdjustPosition reads) and converts the
envcell-local origin through the cell's WorldTransform into landblock
coordinates before the 3x3 walk centres on it — an outdoor Position's
origin is already landblock-local, an envcell's is cell-local, and
skipping that conversion would centre the walk wrongly by up to a
landblock. A not-yet-resident cell record resolves to silence for that
rebuild rather than a wrong walk. Sealed dungeons stay silent, which is
retail-correct.
The user also reports interiors carrying their own local sound in retail
(hearth-type emitters). Statics already register their sound tables and
route animation hooks, so the expectation is that the seen_outside fix
plus existing emitters covers it; re-listen decides, and anything still
missing becomes a precise follow-up.
Full Release suite: 11,740 passed / 4 skipped / 0 failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Login could hang forever at reveal ready=True with the world never
revealed: UI and sky drawn, geometry absent, client healthy. The player's
first-entry conductor was being terminally dropped by a TRANSIENT
condition.
Mechanism, pinned by probes: the C3c-F2 rearm guard validates the exact
destination cell's prefix admissibility before moving the dormant lease
out of AwaitingCell, but the placement transaction's ring search touches
NEIGHBOUR landblocks and TrySealCollisionEvaluationAuthority covers every
touched prefix. A hard login recenter admits nine landblocks at once, so
a rearm taken while a neighbour's admission was still registered passed
the guard and failed the seal. The operation was left in
AwaitingPreparation, IsDormantLocalActivationAwaitingCell went false, and
EvaluateActivation had no way to say 'retry' - it fell through to
RejectedAuthority, which RuntimeFirstEntryDriveController treats as
terminal. The local player left the pump (pending=0), the movement
controller never published, auto-entry never fired, the reveal never
completed. Timing-flipped: the same binary worked when the rearm landed
outside a neighbour's admission window, then lost that race consistently.
Fix is classification, not state: EvaluateActivation reports DeferredCell
when the abort happens while the dormant lease is still current
(IsDormantLocalActivationLeaseCurrent), so the conductor keeps retrying.
The operation deliberately stays in AwaitingPreparation - the retry
re-runs the full evaluation against fresh state, which is the recovery
contract the publication-state tests already pin (the SAME token
evaluates Evaluated once the authority settles). Genuine discards still
report RejectedAuthority. A first attempt that re-parked the lease to
AwaitingCell was rejected by the test matrix: recovery would then need
the rearm gate, which is stricter than the seal, and the
reentrant-restriction-mutation recoveries hung in DeferredCell.
Seven publication-state tests move their transient-abort assertion from
RejectedAuthority to DeferredCell; the two genuinely-terminal tests
(lease retired) are unchanged. The [wake]/[rearm]/[pump] probes that
pinned the mechanism stay behind ACDREAM_PROBE_PARK=1 with the rest of
the C4 family.
Exonerated by experiment before the fix: ACE (wire capture shows
PlayerCreate sent; retail logs in fine) and the portal-cue commit
2914e43a (full revert stalled identically).
Verified: 2/2 live logins reach auto-entered player mode and reveal
event=complete, with the probe showing seal-refused -> retry -> recovery
in flight; full Release suite 11,740 passed / 4 skipped / 0 failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Losing window focus calls CameraPointerInputController.HandleFocusChanged
-> MouseLookController.EndForLifecycle -> PlayerMovementController
.EndMouseLook, and EnsurePublishedForRuntimeOperation throws when the
controller exists but is not yet published (mid-login) or already retired
(post-logout). A focus callback can land in either window, so a simple
alt-tab during the login stream took the whole process down with an
unhandled InvalidOperationException. Hit live during the Campaign A
listening-session launches.
EndAndRestoreCursor already guarded 'no controller'; publication state is
the finer-grained form of the same condition, so the guard is completed
with the new CanExecuteLiveMovement predicate (the exact lifecycle set
EnsurePublishedForRuntimeOperation accepts) rather than wrapping the call
in a catch. Cursor restore still runs unconditionally - presentation is
always safe. Published-controller behaviour is unchanged.
Also files issue #357: the login placement stall this session exposed
(reveal ready=True, player Place edge never executes, world never opens).
That one is NOT fixed here - full evidence chain, wire capture, and probe
output are in the issue. It is a placement-domain bug and blocks the
Campaign A listening gate.
Suite: green except the known load-dependent measurement flake
(RuntimeCollisionReportingStateTests allocation pin), which passes in
isolation.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Slice A4 hung UI_EnterPortal on TeleportAnimEvent.EnterTunnel — the first
tunnel-family frame — so the cue landed a whole TunnelFadeIn after retail
plays it. Retail's site is gmSmartBoxUI::BeginTeleportAnimation
@0x004D638E, i.e. Begin(), which the sequencer already marks as
TeleportAnimEvent.PlayEnterSound.
That event has existed since the R6 portal-space work, complete with a
'Begin(): sound_ui_enter_portal' comment, and no consumer has ever
handled it — the switch in LocalPlayerTeleportController had cases for
Place, EnterTunnel, PlayExitSound and FireLoginComplete only, so the
sequencer emitted PlayEnterSound into nothing. A4 filled the gap in the
wrong place rather than filling it.
Both cues now go through named presentation methods driven by the
matching events: PlayEnterCue on PlayEnterSound, PlayExitCue on
PlayExitSound (the TunnelFadeOut -> WorldFadeIn edge, @0x004D7405, the
same tick the world viewport is revealed). EnterTunnel/ExitTunnel are
visuals again. The exit cue was already firing at the right moment, since
ExitTunnel was called from inside the PlayExitSound case — correct by
accident, explicit now.
Found by the user asking when the recall cues play.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
E.2 described the audio engine as a retail-faithful 3D pool with
quieter-slot eviction and probability-weighted variant picking. Campaign
A disproved all three: retail creates every gameplay buffer 2D, evicts on
DAT priority rather than gain, and treats probability as a Bernoulli
silence gate — the 'weighted picking' was the bug that made creature
chatter fire ~20x too often.
Also fixes the A6 ledger SHA, which was stamped pre-amend.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Retail EoR has no music system: the linked winmm MIDI player has zero
callers, 'music' appears zero times in the 65 MB decomp, SoundType has no
music member, InitPrefs registers no music key, and the install ships no
music files. So PlayMusic/StopMusic/MusicVolume and the AudioSettings
Music knob are deleted rather than left as an API modelling dead code —
the string-keyed signature was the tell, since every other entry point is
DID-keyed. Old settings.json files carrying a 'music' key still load; the
reader ignores unknown keys and the next save drops it.
The Ambient slider is now surfaced, because slice A5 gave it something to
drive, and its default returns to retail's 1.0 from an invented 0.8 —
InitPrefs defaults every sound preference to unity. The panel rule is
unchanged: no slider that does nothing.
r05-audio-sound.md gets a SUPERSEDED banner naming its five wrong
sections (falloff, pan, voice pool, selection, music, ambient) so a
future reader reaches the lane notes instead of the Ghidra-era reads that
this campaign spent its first two slices undoing.
TS-9 re-scoped from 'any MP3 cue' to the measured blast radius: exactly 1
MP3 among 786 shipped waves, a ~2 s mono clip. Its original framing
assumed a music system that does not exist. The ADPCM count remains
unmeasured and is named as the open question.
Deferred deliberately: #321's sound-cache decode-dedup race. It is a
pre-existing concurrency flake rather than audio-parity behaviour, and
shipping a speculative fix to a race I have not reproduced is exactly the
shortcut this project's no-workarounds rule exists to prevent.
Campaign A is code-complete; the plan carries the closeout.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
acdream had no ambient system: StartAmbient minted a handle and played
nothing. Retail's is a weighted-accumulation + timer-queue engine, not
looping voices. On every objcell change (24 m) CellManager::ChangePosition
rebuilds per-sound weights over the 3x3 landblock ring x 64 land cells
each, decoding each cell's terrain word through the region file's
terrain -> scene -> AmbientSTBDesc chain; playback is a min-heap of
absolute deadlines drained from the frame tick, where each pop fires a
one-shot and re-arms.
A continuous bed (base_chance == 0) is non-positional, crossfaded by its
share of the TOTAL weight, and re-fired every min_rate seconds — that
rate is the author's intended loop period, and re-firing is how retail
fakes a sustained bed with no looping voice, re-rolling the variant and
the crossfade each time. An intermittent one keeps its authored volume,
plays at a random accumulated compass bearing at min + (max-min)*t^2,
and is dice-gated. Indoors is silent by design: CEnvCell's contributor is
a folded ret and EnvCell carries no sound data.
The Opus review caught four bugs before this landed, one fatal:
- Cell offsets were built in ABSOLUTE world coordinates and differenced
against the listener's STREAMED-frame position, so every one of 576
offsets came out ~32 km, every contribution was culled, and the whole
feature was silent with nothing logged. Offsets are now landblock-local
the way Position::get_offset builds them, and the streamed-frame
position is carried separately for playback, where it belongs.
- The cell's weight was added to the shared denominator once per
DESCRIPTOR instead of once per CELL, dividing every bed's crossfade by
the table's entry count — enough to push a typical authored volume
under the 0.03 audibility floor.
- The drain used where retail's UseTime is strictly
below, so a descriptor authored with a zero rate re-armed at the same
instant and spun the frame forever.
- Arming only enqueued; retail's UpdatePlayQueue PLAYS and then re-arms,
so a newly audible ambient was silent for a full period after the
crossing that made it audible.
Also: beds now go through retail's single 16-voice priority pool rather
than acdream's UI pool (retail has one pool; parking beds in the UI pool
let an A4 portal cue chop one mid-wave and discarded the authored
priority), and CalcDir's in-block test is XY-only, since CalcWeight
includes Z on purpose and CalcDir excludes it on purpose.
Two behaviours are knowingly incomplete and registered rather than
guessed at slice end: TS-66 (sky-lit interiors should keep the outdoor
set) and TS-67 (contribution weight is computed in-plane). Retires TS-29.
The frame-loop hook is a typed IAmbientFramePhase, not a callback — the
first attempt used an Action<float> and the architecture guard
ExtractedUpdateOwners_DoNotRetainAnonymousCallbacks correctly rejected it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Retail's UI sound bank was absent, so three families of cue were silent:
the portal enter/exit stingers, the AdminEnvirons dungeon atmosphere
(chanting, drums, whispers, thunder — what players remember as dungeon
'music'), and every other interface slot.
The bank's DID is not a literal anywhere in retail: GetUISoundTable
@0x00563FB0 asks GetByEnum for enum slot 7, and DBCache::GetDIDFromEnum
@0x00413940 resolves it through two EnumIDMap hops off the portal dat
header's master map. UiSoundTableResolver walks that chain the way
RetailCursorResolver already walks it for cursors. Against the shipped
dats it resolves to 0x2000004B, and that table holds exactly the 32 UI_*
slots (UI_EnterPortal 0x6A .. UI_Thunder6 0x8A) — content that confirms
the walk independently of the decode. UiSoundTableResolutionTests pins
the walk, the DID, and the content, and skips when dats are absent.
Two corrections to the research along the way. The lane-5 note recorded
GetByEnum's arguments transposed: the 0x22 it called a fileType is the
CACHE type (CLOCache(cache, CSoundTable::Allocator, 0x22)) and the real
second-hop key is 0x10000003; walking it the other way finds nothing. And
its claim that the interface volume pref applies is wrong — GetAttenuation
with ambient=0 multiplies by the EFFECT knob, so retail's
interface_sound_volume stays the dead knob lane 1 byte-decoded it to be.
EnvironSoundCueMap is an explicit 21-case table read straight out of
Handle_Admin__Environs @0x0055DE20, not arithmetic: codes 0x65..0x72 sit
0x11 below their SoundType, but 0x73/0x74 have no case, so 0x75 lands on
UI_Squeal (0x84) where an offset gives 0x86, and the switch ends at 0x7B
with no 0x7C case. Verified case-by-case against the decomp rather than
from the lane note, whose tail table was ambiguous.
Cues are attached where retail plays them: the teleport-animation
boundary for the portal pair, and the AdminEnvirons handler for the
stingers. PlaySoundFromCenter's pan-0 / distance-0 shape is what
PlayUiWave already implements after A2.
Retires TS-54. Narrows AP-115 to its notice-presentation residual.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
acdream never parsed retail's Sound event, so every server-driven cue was
silent: melee hits and wounds, wield/unwield, pickup/drop, lockpicking,
lifestone bind, spell resist, trap triggers, item mana depletion.
SoundEvent parses the 16-byte message (guid, SoundType, f32 volume) whose
layout three oracles agree on: retail CM_Physics::DispatchSB_SoundEvent
@0x006AC760 reading buf+4/+8/+0xC, ACE's GameMessageSound at declared
length 16, and holtburger's PlaySoundData.
Playback reuses EntityEffectController's existing per-guid queue rather
than adding a second one, because retail routes sounds through the SAME
CObjectMaint blob queue as F754/F755: an event for a guid the client does
not know yet is parked and drained by HandleCreateObject, so a creature
that spawns and immediately grunts still grunts. Dropping it — the
obvious alternative — would silently lose the cue. Sound joins Direct and
Typed as a third PendingEffect kind so one readiness edge releases the
whole mixed stream in order.
AudioHookSink.PlayServerSound reproduces two decoded asymmetries with the
animation-hook path: the sound plays at the WIRE volume and the
SoundTable entry's volume is ignored (the hook path does the opposite),
while the entry's probability still gates it and its priority still
drives eviction. An object with no SoundTable plays nothing, matching
CPhysicsObj::play_sound @0x0050F460's early return.
The no-window host parses and discards, exactly as it does for F754/F755
— sound is presentation.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Retail is not a 3D audio engine. Every gameplay buffer is created with
m_3D = 0 and the DirectSound 3D listener the client sets up is dead code;
spatialization is two CPU scalars per voice, frozen at emission. This
slice ports that math and demotes OpenAL to a voice bank.
RetailSoundMixer (new, Core) carries the byte-decoded curve from
SoundManager::GetAttenuation @0x00550020: g = dist < 5 ? vol : 25*vol/d2,
clamped to 1 BEFORE the single master multiply, db = ceil(20*log10 g),
with a hard -50 dB floor at which retail does not start the voice at all
(audible radius ~94.2 m at unity). Pan is PlaySoundInternal @0x00550170's
(int)(-15*sin(delta-bearing)) in whole decibels, truncating toward zero,
forced to dead centre when (int)distance < 5, with no front/back and no
elevation cue. Every AL source is now source-relative with rolloff 0 and
the global distance model is None: AL's InverseDistanceClamped was
first-power (2/d), quieter than retail up close and far louder at range
with no cutoff whatsoever. That was the largest audible divergence in the
subsystem (AP-28, retired here).
RetailVoicePool (new, Core) ports the allocator at 0x0054FEC0: ring scan
for a free or finished slot, then evict the first slot whose DAT priority
is strictly lower, else drop. Eviction compared GAIN before, so a loud
unimportant sound could silence a quiet important one. It lives in Core
because the engine's play path talks to native AL handles and could not
be tested; the pool now has 12 conformance tests.
The listener keeps using the camera position, which the decode shows is
retail-faithful (SmartBox::set_viewer @0x00452D36 hands the same collided
camera Position to SoundManager) — only the heading extraction changes,
since retail reads one compass bearing and never a forward/up basis. An
earlier draft of the plan called this a defect; corrected in the plan so
it is not fixed backwards.
Opus review found and this commit fixes: a linear pan-to-azimuth mapping
that saturated to full separation at 30 degrees (OpenAL Soft's own
speaker angle) where retail gives 15 dB — now inverts the constant-power
pan law, so full deflection reaches 0.776 of the arc and both channels
stay live; the stale FUN_00550ad0 / gain-eviction class header, which
contradicted the register row this commit writes; missing discriminating
tests for clamp order and pan truncation; dead PlayingGain state whose
comment invented a retail symbol; and a third in-tree copy of
Position::heading, now delegating to MoveToMath.PositionHeading.
MasterVolume folds into the mixer's one multiply instead of AL listener
gain, so the cutoff, radius and dB quantisation move with the slider.
Register: AP-28 retired; AP-173 (pan law), AP-174 (volume taxonomy),
TS-64 (two unimplemented sound prefs), TS-65 (volume-squared quirk,
applied on the ambient path only) filed. Research note corrected twice
where its summary contradicted its own decode (30 m dB, floor vs trunc).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The SoundTable probability field is a Bernoulli play/skip gate applied at
the play site (SoundManager::PlayProbability @0x005500E0), not a selection
weight — and variant selection (SoundManager::GetSound @0x00550680) is a
uniform index over (n-1) that ignores probability entirely. SoundCookbook
did the opposite: a cumulative-distribution walk weighted BY probability,
short-circuiting single-entry lists before rolling at all.
A dat census says 4,183 of 4,184 entries are single-entry and 686 of those
carry probability < 1.0, so the gate was categorically absent: Speak1 idle
chatter authored at 0.05 fired every trigger (~20x too often), wound/attack/
swoosh variants never dropped, and six 0.0001 entries always played.
Split into retail's two steps (PickVariant + PlayProbability, composed by
Select) over a new ISoundRandom modelling both retail roll ranges: the
variant roll clamped below 1.0 (0x00797D48) and the gate's 1/32767 grid,
which is why 0.0001 resolves to ~1.2e-4. PickVariant reproduces retail's
(n-1) off-by-one verbatim per the port-faithfully rule — the last variant
of a multi-entry sound is unreachable, costing exactly one wave
(0x0A00051E) in the shipped dats.
Also removes invented mechanism this review disproved: the dead Core
SoundEntry/ISoundCache scaffold (PitchMin/PitchMax, Loop, Is3D — retail
never calls SetFrequency, never sets the loop flag, and creates every
gameplay buffer 2D), the engine's pitch plumbing, the int 0..7 priority
cast (the dat field is a float in [0,1]; 4,100 entries collapsed to 0),
and the clamp-at-the-field on volume (an unbounded gain retail clamps only
after the distance divide).
Tests rewritten as conformance against the disassembled values, replacing
a self-referential suite that pinned the wrong model.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Full review of the audio subsystem against the named 2013 retail decomp,
with byte-verification of every load-bearing float compare (five BN
polarity/constant elisions caught). Headlines: retail is a CPU-side 2D
pan+gain engine (no 3D listener in use); the SoundTable probability field
is a Bernoulli SILENCE gate our SoundCookbook never applies (4,183/4,184
entries are single-entry and we short-circuit them); 0xF750 server sounds
are entirely unhandled; ambients are region-authored weighted one-shots
(indoors silent by design); and retail EoR has NO music system at all.
Plan proposes slices A1-A6; awaiting user go.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The green ring is retail's own art: every UIItem cell carries an
authored DragAccept child (catalog 0x21000037, child 0x1000045A), and
the spell bar's drag-over handler (SpellCastSubMenu::OnItemListDragOver
@0x004C5990) flips it to the Accept state (0x10000040 -> surface
0x060011F9) for any spell payload. Ported through a per-slot
SetDragAcceptVisual seam + a catalog DragOverAcceptance hook; other
lists are untouched (null acceptance = neutral). A polarity error in
our older docs (Accept/Reject state ids swapped) was corrected against
three independent sources; the shipped art was always right, only the
labels lied.
The ring shares ONE landing computation with the drop
(FavoriteDropIndex) — and that requirement exposed a genuine #354
off-by-one: the empty-tail path double-applied the -1 adjustment
(retail gates it on the lift's removal @0x004C7157), landing a
reordered spell second-to-last instead of last. Fixed;
discriminator-verified both ways. AP-172 narrowed + its false
empty-tail claim corrected.
Clean-room complete solution: 11,545 passed / 4 skipped / 0 failed.
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Everything already existed — the drag payloads, the favorite wire pair
(0x1E3 add-at-position / 0x1E4 remove, byte-confirmed against retail's
Event_AddSpellFavorite @0x006A0F70 and ACE), the insert-shift state
ops. The bug: lifting a favorite fires SpellbookChanged, the next
per-frame Tick rebuilt the bar, the rebuild flushed and recreated
every cell, and UiRoot's subtree-removal safety net canceled the
in-flight drag whose source had just been destroyed — one frame after
every lift, before any drop could land.
The rebuild now defers for the duration of the drag gesture, and the
drop ports retail's own -1-if-lifted-before-target index adjustment
(SpellCastSubMenu::AddFavorite @0x004C7060) so final positions are
byte-identical: insert-shift, not swap; drag-out still deletes (the
lift's removal stands on a missed drop, retail's shape). The
real-pointer-pipeline test fails against the pre-fix code with the
exact cancellation and passes after; a discriminator pins that
physical-item drop handlers reject the spell payload.
AP-172 files the one presentation divergence (mid-drag reflow happens
on release, not continuously) — renumbered from the agent's AP-171
draft, which collided with the same-day double-click row. #354 filed
and closed.
Clean-room complete solution: 11,541 passed / 4 skipped / 0 failed.
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Slices 5+6 close together on the user's final gates ("Ok fixed" on the
live purse repaint, the slider/wrap/double-click passes before it).
The plan carries the full closeout: what shipped, the four-review /
34-defect + eleven-live-finding audit trail, the two latent crashers
the arc exposed, and the deferred remainder with its issues and
register rows. CLAUDE.md's Current state flips the program to
COMPLETE; #353 closes user-passed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The money handler refreshed only the Buying/Selling tab summaries;
the Items tab's "(you have Np)" tail rebuilt only on selection
clicks. RefreshSelectionDisplay rides the same ObjectUpdated money
path now.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
RightAligned only applies on UiField's single-line renderer; the entry
was falling into the multi-line path (ignoring the alignment AND
re-scrolling its extents per keystroke — the reported edit flicker).
A 14 px numeric entry is single-line by construction; OneLine = true
routes it correctly. The 233 now sits flush against the slider per the
authored HJustify=2.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Double-clicking a vendor shop item now buys through the Buy button's
exact quantity/price path — retail has NO double-click-to-buy (the
named table sweep's negative evidence stands); the user chose the
addition explicitly and AP-171 records it.
#353 (pre-existing, user-reported): the stack-count entry is AUTHORED
HJustify=2 — right-justified flush against the slider on its own row —
and UiField already supported RightAligned; nobody had honored the
authored value. The name element is AUTHORED two lines tall (H=31,
W=140): long names now word-wrap at the authored pixel width onto a
second centered row via two stacked one-line labels reusing the
existing centered draw path (WrapNameTwoLines: greedy word break, no
hyphenation, second row clips like retail).
Ten SelectedObjectController structure tests updated from
single-label to first-label access. Lesson re-learned the hard way:
the first "green" run used a stale TEST assembly (only the App
project had been rebuilt) — the clean-room caught it, per
feedback_stale_build_artifacts. Full App 4,329/3 and Core 4,381/1
verified green on properly rebuilt assemblies; the one transient
Core Release failure did not reproduce and is noted on #351.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The vendor-verify gate's trace proved the entire walk-to-use chain
succeeding — arrival natural, Use dispatched, UseDone, the full
117-item ApproachVendor — and the panel still never appeared: the
range watcher's plain center-distance shortcut (AP-160) closed the
session the same frame it opened. The walk stops where the server
accepts (cylinder-gap: center minus both radii), which lands ~4.3 m
center against the vendor's authored 3 m — inside the acceptance
band, outside the watcher's bare-center check.
EnforceRange now measures cylinder-gap with both radii resolved
through the SAME ResolveObjectTableHost seam the movement arrival
uses — the seam whose absence was AP-160's original justification,
created by the previous commit's fix. The watcher and the walk agree
by construction. Unresolvable hosts degrade an operand to center
distance (close-early only, never holding a session ACE ended);
heights pass 0 (the host surface exposes radius only). AP-160
narrowed; #352 files the deferred cylinder-vs-center discriminating
unit test (needs a 38-member host fake; the live gate covered the
behavior today).
Clean-room complete solution: 11,536 passed / 4 skipped / 0 failed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Both chains pinned by the live [vendor-diag] run (vendor-diag.log)
after three code-reading rounds each failed:
The split bar: ACE serializes descStackSize=1 for EVERY browse row
(live wire, log 343-348) — the R1-era "ACE never populates desc"
claim is retracted with the line quoted. Retail's vendor sites read
pwd._maxStackSize directly (four sites, incl. UpdateItemsList
@0x004c1ea0 stamping min(remaining, _maxStackSize));
ResolveAuthoredStackSize flips to max-first for its vendor-only
consumers. Taper ceiling 1000, scarab 100, seed 1 for exempt.
Pricing still reads the desc (per-1 values on ACE).
Walk-to-use: the local player's getObjectA seam was bound to
TryGetPhysicsHost, which resolves only INSTALLED physics hosts — a
never-animated vendor has none, so TargetManager.SetTarget got null,
the MoveToObject armed with zero nodes, and UseTime never dispatched.
The log's natural=False completions were the user's own movement keys
(retail-correct input-edge cancels); attempt 4 worked because the
greeting animation had installed a host. RuntimePhysicsState gains
the retail CObjectMaint::GetObjectA seam (bound canonical resolver
with installed-host fallback); the graphical host binds the SAME
lazy-minimal-host resolver every remote already uses — whose own doc
comment names this exact never-animated hazard. The reservation
release was already correct (2b premise refuted with evidence); the
production-wiring invariants are now pinned by four new tests
including the pre-fix pathology as a permanent sabotage control.
AP-169 rewritten a second time, honestly. The [vendor-diag] probe
family (ACDREAM_DUMP_VENDOR) lands env-gated for future live triage.
Clean-room complete solution: 11,536 passed / 4 skipped / 0 failed.
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
R1 the split bar's operand is the item's authored MaxStackSize —
three retail sites read pwd._maxStackSize directly (InqListSlotCount
pc:200052, buy-button cases pc:203996/204086) where ACE never fills
the desc stack and standard stock is unlimited. Threaded StackSizeMax
end to end with one shared resolver; the two literal _maxStackSize
sites are now byte-exact; AP-165 retired, AP-169 corrected.
R2 walk-to-vendor never opened because GetUseRadius used an UNCITED
3m Creature heuristic as the local stop distance while ACE's poll
demands the authored radius (default 0.6 m) — the walk stopped and
the Use fired far outside acceptance. Now reads the wire-authored
spawn UseRadius with ACE's exact fallback; heuristic constants
deleted. A first sabotage attempt was non-discriminating
(coincidental 0.6) and was corrected — the discriminating version is
what landed.
R3 the Buying/Selling purse summaries ("Buying %d %s worth %hsp" /
"You have %hsp") recovered from the binary data segment where BN
mis-attributes the Buy-side literal; wired to staging and money
changes on the four authored text elements; AP-166 narrowed to the
pending-sell highlight.
Clean-room complete solution: 11,528 passed / 4 skipped / 0 failed.
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Four live findings, each with the paper-verification failure named:
G1 the container-capacity guard counted containers by a local
type/capacity heuristic that over-classifies ordinary items;
retail buckets from the wire's ContainerProperties at insert. Now
reads ClientObjectTable's existing ContainerTypeHint (AP-168 narrowed
to the shop-stock half; a pre-check must never false-block).
G2 the amount bar never showed live because ACE never sets StackSize
on browse listings — DescStackSize is null for every real vendor item
and the C4 paper test hand-set the field, bypassing the materializer.
The materializer now falls back to the packed supply count (AP-169,
ACE adaptation); the new test drives the REAL materializer.
G3 an out-of-range Use now dispatches ON ARRIVAL (pickup's shape):
ACE's HandleActionUseItem only opens the vendor when the Use finds
the player in range — a click-time send is greeted and dropped
(AP-170, ACE adaptation; retail's server walks the player, ACE
does not).
G4 bought items appended because ACE's placement echo (UIQueue) can
beat the CreateObject (SmartboxQueue) — cross-queue, no ordering
guarantee — and the early echo was silently dropped. ClientObjectTable
now stashes unresolved placements and replays them at Ingest: buys
land at the retail list head. No register row — this RESTORES parity.
Clean-room complete solution: 11,521 passed / 4 skipped / 0 failed.
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
All thirteen findings, each anchored in recovered bytes or pc reads:
Buy All now runs retail's four PRE-SEND guards in order (pyreal and
alt-currency affordability, container and item slot capacity; strings
recovered from .rdata at 0x007b57b4/0x007b5750) — a rejected batch can
no longer destroy the staged list. Staged adds ACCUMULATE with the
5000 cap ("I can't possibly sell you that much!..." @0x007b59d8) and
the shop rows decrement/restore per RemoveFromShop. The max-value sell
rejection exempts trade notes — the raw bytes at 0x005d1add are `not`
(bitwise), not the pseudo-C's misleading `!`, and the early ret skips
the min check too. BF_RETAINED gates selling end to end (the bit was
already on ClientObject; AP-164's three claims were all false once
traced — RETIRED). Dragging over the vendor window auto-opens the
Selling tab per UpdateDragOver — with a correction to the review's own
citation: token 0x100000cd is the SELLING page, the guard is
"don't reopen the current tab." Sells are full-stack-only (three
retail sites; "Cannot sell part of a stack" @0x007b57ec) and Sell Item
acts on the global selection unconditionally. The confirm string gains
its byte-true trailing '?', dies with the session, staged-row
highlights repaint, dead guids unstage with retail's shopping-list
notice, and move-to-use no longer walks to targets the dispatch would
refuse.
AP-162 narrowed, AP-164 retired, AP-167/AP-168 filed honest.
Clean-room complete solution: 11,508 passed / 4 skipped / 0 failed.
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
C1 an out-of-range Use now approaches first via the existing
client-predicted BeginApproach (Pickup's far-range shape mirrored;
retail's ItemHolder::UseObject @0x00588A80 has no range check and the
dispatch stays immediate). C2 Add-to-List stages into the Buying tab
via VendorStagingList (RemoveProfileFromList's two shapes,
pc:200497-200537), Buy All sends ONE batched 0x005F and flushes
staging on send exactly as retail does (SendShopEvent -> Flush,
pc:204075-204076 — not UseDone-gated), and X-close over a non-empty
staging list shows retail's confirm string recovered verbatim from the
binary data segment (0x007b5bd8) through the existing dialog factory.
C3 the Selling tab's list is the sole drop target (retail's single
IsAncestorOfMe gate, pc:204229-204246); VendorSellAcceptability ports
InqAcceptability with all rejection strings recovered verbatim from
the raw data segment; the sell side prices with BuyPrice (retail's
inverted naming: what the vendor PAYS) and 0x0060 carries no trailing
currency field, unlike Buy. C4 the status-bar reproduction test PASSES
against the production toolbar mount — retail's toolbar shows count +
name with the split bar and NO price parenthetical (that figure is the
vendor row's own cost text); no code change, the live gate referees.
C5 pack order verified correct, untouched.
Register: AP-161 narrowed to its two pre-existing cosmetic gaps;
AP-162 extended over Buy All; AP-164 (non-sellable bitfield
unmodeled), AP-165 (DescStackSize for _maxStackSize in the removal
test, bounded), AP-166 (purse text + pending-sell highlight cosmetic)
filed.
Clean-room complete solution: 11,482 passed / 4 skipped / 0 failed.
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Three gate findings, each settled by authored data rather than
invention: the button face is retail's two-piece assembly and the
17x19 arrow-cap 0x1000034E now renders with its authored
Normal(closed)/Highlight(open) states; the popup direction is an
AUTHORED attribute (UIElement_Menu::Open pc:120210-120252 — bool
attr 5, chat authors upward=true, the vendor menu authors nothing and
defaults downward), so both menus are now byte-faithful with no
special case; and the 19/20px text indents were chat-specific
checkbox/LED clearances the vendor rows don't author — measured
against the live retail font, "Spell Components" overflowed by 11px
and now fits with 8px to spare. Chat's menu defaults are bit-identical
and its tests untouched.
AP-161's arrow-cap note closes. #351 files the pre-existing FarLoad
Debug flake (three sightings today, never in clean-room Release).
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
The user's connected gate found five issues; each fixed at the root:
G4 (the discovery): retail's vendor X button calls only SetVisible(0)
(pc:204147-204182) — the SESSION stays open and re-using the vendor
lands on the same-session refresh; the range watcher remains the sole
real close. Our port invented a full teardown on X, which is exactly
why reopening died. The Runtime fixture proves the wire dispatch was
never the problem; ACE has no already-open short-circuit.
G3 (regression from the drag-suppression fix): denying IsDragSource
also dropped press capture, so clicks fell through to window-drag.
UiItemSlot.HandlesClick now claims presses for any occupied cell
independent of drag eligibility — clickable and draggable are separate
concerns.
G5: the authored popup 0x21000043 is ONE scrollable column with a real
scrollbar subtree (live-dat scan: ListBox 0x10000350 + scrollbar
0x10000351), not a 3x6 grid. UiMenu gains an authored-driven
Scrollable mode (wheel, thumb drag, track paging, up/down buttons);
chat's menu is untouched and its ten tests prove it.
G1: retail's cost format is "%s %hsp (you have %hsp)" — the p after
each %hs is a LITERAL pyreal suffix the port swallowed as part of the
specifier. Restored.
G2: the Buying/Selling pages' authored lists (same cell template as
Items) get the empty-slot fill, presentation-only until staging.
Clean-room complete solution: 11,390 passed / 4 skipped / 0 failed.
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
RenderDeltaApplyResult's nine counters accumulate for the lifetime of
a world generation (the cumulative sum is never reset in place) yet
were int where every sibling lifetime counter in the same class was
already ulong/long. A 2h42m single-generation session (login to crash
with zero portals) overflowed one through ordinary per-tick churn from
SynchronizeActiveSources' two call sites per frame. Introduced
0eb66485 (2026-07-24); first reached by the vendor buy gate because
parking at a shop for hours produced the project's first multi-hour
unbroken generation. The vendor materializer was exonerated by routing
analysis (shop-item Ingest/Remove reaches inventory deltas only, never
the render journal).
Widened to long; the per-tick builder stays int and widens implicitly;
the arithmetic stays checked. All 68 render-shadow tests green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
All nine findings from the buy-arc review, at root:
F1 the materializer's retire pass re-checks ownership (guid->vendorId
map; remove only while the live object's ContainerId still equals the
recording vendor) — buying a player-sold UNIQUE no longer deletes the
item you just purchased; the discriminating reparent-then-refresh test
pins it. F2 the cost/name display subscribes to the live split state
and shares ONE quantity computation with Buy (retail re-renders per
slider tick: RecvNotice_StackSliderChanged 0x004C4500) — the sentence
and the charge can no longer disagree. F3 shop rows never mint drag
payloads (UiItemSlot.AllowDragSource gates both IsDragSource AND
GetDragPayload — the second gate was caught by this pass's own test).
F4 sendBuy reports whether anything was sent; a null-session buy
cancels the reservation instead of leaking BusyCount forever.
F5 the retire loop snapshots, isolates per-guid observer failures, and
clears its tracking in finally and Dispose — teardown convergence can
no longer wedge. F6 auto-select is retail's unconditional
first-filtered-item shape (pc:201180-201184; the survival-check was
our invention and the comment claiming otherwise is corrected).
F7 non-stack buys clamp to quantity 1 locally (BuySingleItem
pc:201669). F8 the Add button is hard-disabled until staging exists.
F9 AP-161/162/163 rewritten to the post-fix reality.
Clean-room complete solution: 11,378 passed / 4 skipped / 0 failed.
The #350 render-ledger overflow observed this session is under
separate investigation and is NOT addressed here.
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Three ordered pieces in one landing (the shared controller/composition
files carry all three; the internal order was 6.1 -> 6.2 -> 6.3):
6.1 VendorShopItemMaterializer diff-merges the shop list into the live
ClientObjectTable on VendorState transitions (so client-local close and
session teardown retire the entries too) and never claims a guid it did
not add — ACE's UniqueItemsForSale can re-list a guid a player once
held (AP-163 files the collision-skip; no retail counterpart traced).
Right-click examine on shop items now routes through the ordinary
appraisal path — the 5.4 F7c blocker dissolves with the table entries.
6.2 SelectionChangeSource.Vendor: row clicks, auto-select, and examine
all flow through the canonical SelectionState; the status bar and the
existing byte-faithful StackSplitQuantityState slider light up
unmodified. VendorSplitPolicy is the single 0xDC41CB0 mask owner; the
slider VALUE seeds to 1 for exempt items while maxSplitSize keeps the
stack (the splitSize/maxSplitSize distinction, research §B.3).
Selection clears at retail's actual site — VendorItemsUI::RemoveFromShop
(pc:202848), not a CloseVendor-level clear that does not exist.
6.3 BuildBuy (0x005F): vendorGuid, count, (i32 amount, u32 guid) pairs,
and the trailing alternateCurrencyId the REAL client sends
(CM_Vendor::Event_Buy pc:689288) though ACE's reader ignores it.
TryBuy rides the EXISTING J5.2 one-request-at-a-time reservation and
completes on UseDone; the Buy button disables while a request is in
flight. The reconciliation round-trip (money property update, inventory
CreateObject, ApproachVendor refresh -> panel rebuild) is proven by a
synthetic-inbound test against existing machinery — no new owner.
Register: AP-161 narrowed (selection + examine residuals close;
staging/Sell remain; double-click-to-buy confirmed ABSENT from retail
with negative evidence cited — we match retail). AP-162 files the
conscious no-client-side-affordability-precheck deferral.
Clean-room complete solution: 11,368 passed / 4 skipped / 0 failed.
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
All nine review findings closed at root (one sub-item consciously
deferred):
F1 the category dropdown now draws: sprites/fonts wired and the popup
geometry read from the vendor menu's own authored popup LayoutDesc
0x21000043 (root 0x1000034F — correcting the review's 0x1000014F
transcription) per UIElement_Menu::MakePopup (pc:120705); chat's menu
is untouched and its tests prove it. The new test drives selection
through the REAL open/hit path the review flagged as bypassed.
F2+F3 the selected-item cost display ports VendorItemsUI::UpdateItemsUI
verbatim: quantity via the 0xDC41CB0 split-size mask (whole-stack for
ammo, per-unit for groceries/components; mask lives at the toolbar
SEEDING site pc:198784), plural names with retail's
fall-back-to-singular (pc:409056 — correcting the review's "name+s"
guess), full cost sentences with comma grouping and the player's coin
total, and Buy/Add buttons that disable without a selection.
F4 category switches auto-select the first filtered item (pc:201180).
F5 icon underlay/overlay/effects + plural name forwarded from the
already-parsed wire fields through VendorShopItem to the icon
composer. F6 a DIFFERENT vendor opens on its own first category;
same-vendor refresh preserves per the clamp. F7 scroll resets on
rebuild and authored empty slots fill; the right-click examine route
is consciously DEFERRED (shop items are not in ClientObjectTable and
the appraisal panel hard-requires it — documented, not faked).
F8 VendorState.Apply's fanout gets the same per-listener isolation as
Close/Reset. F9 AP-110/AP-161 wording corrected ("quantity-correct
pricing") and AP-161 rewritten to exactly the remaining conscious
gaps.
Clean-room complete solution with the #348 cursor fix in the same
tree: 11,334 passed / 4 skipped / 0 failed.
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Silk's per-mouse ICursor recreates the native Win32 cursor on every
Image assignment; a per-frame cursor alternation (the pick cursor
flickering between kinds while hovering an ANIMATED NPC — exactly the
stand-at-a-vendor posture) allocated a fresh USER handle each flip
until CreateCursor died with "Not enough memory" and took the render
loop with it (vendor-gate.log, exit 82 — surfaced as one clean stack
by #343's fix, as designed).
GlfwCursorCache restores retail's own shape: each distinct
MediaDescCursor is created ONCE for the process lifetime
(glfwCreateCursor, rejected media cached as permanent misses) and
switching is an O(1) zero-allocation glfwSetCursor. The AP-72
missing-art standard-cursor fallback rides the same cache
(Arrow/Hand/Crosshair/IBeam; anything else keeps the Silk path).
Graphical hosts attach after the native window exists; tests and
windowless hosts keep the Silk path untouched. RetailCursorManager's
dedup and PlanApplication logic are unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The vendor window is retail's own: LayoutDesc 0x21000012, root
0x100000B7, found by enumerating all 101 layouts for the one
containing both known tab controls and clinched by the root's Type
0x10000017 — the literal UIElement::RegisterElementClass id for
gmVendorUI (pc:202075). Discovery evidence and the D0 read live in
the research doc's new §B.4.
D0 corrected two assumptions: retail's category "tabs" are a UiMenu
DROPDOWN fed by a hardcoded 18-row ordered category table (ported
bit-for-bit against our ItemType enum; list always scoped to exactly
one category, first-present wins, selection preserved across refresh
per retail's clamp), and the layout authors THREE tabs — Items
(browse, this slice), Buying and Selling (staged-transaction review,
Slice 6) — decision 4's "browse/Buy tab" names the Items tab retail's
mode-2 OpenTab opens. The non-default tabs render and switch pages
but stay inert, fenced in comments.
VendorUiController mounts Items: category dropdown, icon-cell item
row with the retained scrollbar, per-unit retail pricing via
VendorPricing.SellPrice (the vendor-stock path VendorProfile::
VendorSellPrice feeds), name/cost on selection. The panel is a pure
projection of VendorState — opens on populate, closes on clear; the
close button's VendorState.Close() is its only permitted mutation.
Nothing on the wire.
AP-110 narrowed (vendor leaves the absent-panels list); AP-161 files
the precise Slice-6 remainder (Buying/Selling unwired, Buy/Add
buttons, InqAcceptability). Twelve controller tests on a real-dat
fixture. Clean-room complete solution: 11,323 passed / 4 skipped /
0 failed.
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
The adversarial review's three blocking findings, each fixed at root:
1. A vendor session now CLOSES when its entity retires (despawn,
death, ObjectDelete) and at teleport BEGIN
(HasPendingTeleportStart || IsTeleportActive at the existing
per-frame seam — both hosts funnel through
RuntimeWorldTransitState.TryQueueTeleportStart, which flips the
pending flag strictly before activation). The previous permissive
early-return stranded the session forever: panel pinned to a stale
guid, ActiveVendorId swallowing Use for the rest of the session.
2. VendorShopItem carries the desc's stack size, and
VendorPricing.PerUnitValue ports retail's stack-total division
(VendorProfile::VendorSellPrice 0x005D1B00: <= 0 guard, integer
division) — a stack of 50 arrows now prices per arrow, not at 50x.
3. VendorState.Close() guards its observer fanout with the
dispatcher's catch-and-log semantics — a throwing panel listener
can no longer propagate into the unprotected per-frame path.
Register honesty rides along: the 0.6 m UseRadius fallback was
acdream's invention (ACE's CheckClose has no fallback; retail passes
the raw authored radius) — removed, the watcher now uses the raw
radius and AP-160's citations are corrected and extended with the
accepted-position-snapshot cadence; AD-72 files VendorPricing's
double-vs-x87-extended narrowing (AD-33's class, bounded by the
±0.1 margin).
Nine tests added. Clean-room complete solution: 11,311 passed /
4 skipped / 0 failed.
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
The sole VendorState joins the J4.2 inventory owners: populated by the
new 0x0062 ApproachVendor route (parse via VendorApproach, wire-to-
domain mapping at the routing seam, silent-drop on malformed like every
sibling), borrowed by both graphical and headless hosts, and torn down
through the EXISTING ExternalContainer reset stage — session reset,
portal-out, and logout all funnel through the one mechanism. Close is
client-local per retail (nothing on the wire): a range watcher rides
the existing per-advanced-frame publishMovement callback, using the
vendor's own authored UseRadius (ACE's 0.6 m fallback when absent).
The dormant ItemInteractionController ActiveVendorId seam is finally
wired as a live delegate — real id while open, 0 the moment the
session clears.
AP-160 filed in this same commit: the watcher measures plain 3D center
distance rather than retail's cylinder-gap, because Runtime has no
per-NPC collision radius/height source; bounded sub-meter, client-
local UI only.
Twelve Runtime tests: populate/field mapping, vendor replacement,
range clear + within-range retention, all three generation teardowns,
the ActiveVendorId seam, malformed-event drop. Clean-room complete
solution: 11,302 passed / 4 skipped / 0 failed.
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
VendorState sits beside ExternalContainerState (contract decision 1)
with the same shape: private setters, Changed event, Reset with
AggregateException fanout; domain-shaped like ContainerContentEntry
since Core cannot reference Core.Net. No Runtime wiring, no UI — 5.3's
job.
VendorPricing ports ShopSystem::BuyPrice/SellPrice (0x006B6120/
0x006B6180) faithfully: retail's literal three-way branch survives,
including the unreachable-with-real-data negative -1 sentinel that
ACE's Math.Max(1, ...) collapse erases — equivalence for legitimate
inputs is hand-proven and documented rather than silently assumed.
Seven conformance tests with hand-derived golden values (float32
semantics verified independently), covering rate=1.0, fractional
rates, value=0, the rounding-sensitive halfway case, stack
multipliers, the ItemType rate-override branch, and the sentinel.
Clean-room complete solution with 5.1+5.2 in place: 11,291 passed /
4 skipped / 0 failed.
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
VendorApproach.TryParse reads the vendor profile and the item list per
the byte-verified field table (research doc §A.2), each item through
the shared PublicWeenieDescParser from 5.0 — zero duplicated parsing.
One wire detail the research table did not spell out, found by
re-reading ACE's writer and confirmed independently in Chorizite's
generated readers: every object body is 4-byte-aligned at its END, so
back-to-back vendor items need an explicit AlignTo4 between entries
(CreateObject never needed it — nothing follows its body). Pinned by a
dedicated test forcing a real 2-byte misalignment via AmmoType.
Stack-size sign extension cross-checked against holtburger.
Six tests: field-order with distinct literals, empty list, and
truncation at each structural boundary — mid-item-tail truncation
deliberately inherits 5.0's established non-throwing partial-item
contract instead of asserting null everywhere.
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
The WeenieHeader fixed prefix plus the ~300-line conditional
optional-tail cascade moves verbatim from CreateObject.TryParse into
PublicWeenieDescParser.Parse (PublicWeenieDescBody.cs), so Slice 5.1's
vendor-item parser can share it instead of duplicating it — each shop
item on the wire is a full CreateObject-style PublicWeenieDesc
(research doc §A.2). Same field order, same nested try/catch swallow
shape, same truncation messages; CreateObject composes its Parsed from
the returned record. Zero test files changed; Core.Net tests 764/0/0
unchanged; clean-room complete solution 11,271 passed / 4 skipped / 0
failed.
Per the Slice 5 contract (decision 2): extraction FIRST, as its own
bisectable commit, before any vendor code exists to call it.
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
The round-2 cdb capture is decisive: during a live retail glide,
edge_slide fired ~1.5 times per find_transitional_position — the
arm/move alternation's exact signature (3 entries on the arming tick,
0 on the moving tick) — with cliff_slide in lockstep, step_down at
2.5x, step_up 0, and every stack sample on our identical call path.
cliff_slide's bytes match our port and ACE's (compare constant at
0x794610 verified 0.0), and the user could not distinguish the two
clients side by side. The "retail redirects within the tick" premise
misread round-1's set_sliding_normal cadence (per-event, not
per-tick, so its 1:1 ratio with edge never discriminated anything).
The alternation-tolerant assertion in Issue345SteepSlopeGlideTests is
therefore the CORRECT retail-shape pin from both sides; its comment
now cites the capture instead of calling the shape a residual. The
#269 note is honest the other way: the hope that a within-tick port
would explain that feel residual is withdrawn with the premise.
The temporary Scratch347 diagnostic is deleted. Capture evidence:
345-glide-stacks.cdb.log (repo root, untracked, cited from the
contract's RESOLUTION section).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The cliff_slide arms are conformant in ACE, our port, and the bytes
(compare constant at 0x794610 verified 0.0), the round-1 slidn:edge
ratio (538:594) refutes a retail retry storm, and the user's
side-by-side speed observation fits alternation. Round-2 cdb script
now counts find_transitional_position; H-A (identical, retire AD-70)
vs H-B (within-tick yield) resolves on one ratio. The temporary
Scratch347 diagnostic test rides along until #347 closes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Retail's OBJECTINFO::validate_walkable @0x0050d010 initializes its
return slot to OK (0x0050d025) and assigns ADJUSTED only inside the
below-plane guard, immediately after the push executes (0x0050d249).
The guard-fail path — grounded, OnWalkable, plane too steep — jumps
past the contact write, the push, and the assignment (0x0050d1b9 ->
0x0050d251): retail deliberately IGNORES the steep plane at primary
validation so the insert proceeds, the step-down phase fails on the
steep landing, and the edge family produces the per-tick lateral
glide. ACE flattened this into an unconditional return Adjusted
(ObjectInfo.cs:169) and we inherited it; our TransitionalInsert then
retried the byte-identical Adjusted forever — the user's
stop-instead-of-slide.
Evidence chain: the user's retail observation (the axiom), the live
cdb glide profile (edge_slide/cliff_slide 594 each in lockstep,
step_up 0), the D0 implementer's correct STOP (fixtures reproduced
the stuck fingerprint while faithfully executing the ACE-shaped
reading — refuting the reading, not the code), and the capstone
byte-decode both Opus reviewers re-derived independently, including
the stack-slot frame arithmetic and every ret site's eax.
The conformance fixture is the live topology: flat and steep terrain
triangles sharing ONE cell's diagonal (a cell-boundary face does NOT
reproduce the loop — the cell-scoped primary sample never validates a
neighbour's triangle — and is pinned as supplementary). Sabotage:
restoring the unconditional Adjusted reds the discriminator with the
exact stuck position (0.325 m lateral, 28/30 stuck ticks vs 2.602 m /
14/30 fixed; reviewer B's independent five-angle table is monotone
10-85 degrees). Stuck ticks are counted from positions so the
assertion survives the eventual probe strip.
In-game glide gate PASSED 2026-08-08: "Well it works, we are sliding.
I cant detect any speed change from retail."
Filed alongside: #347 + AD-70 (our glide alternates arm/move at half
retail's per-tick rate — retail redirects within the tick; next up by
user direction), AD-71 (the guard's mutable WalkableAllowance operand
vs retail's fixed is_valid_walkable global — now return-value-bearing),
and the reviewers' named residuals in the #345 closure entry
(placement-arm flip, other-cell coverage gap, EdgeSlide-less
projectiles, ACE's server-side shared misport predicting remote
drift-then-snap on steep terrain). The unported IsViewer arm of
validate_walkable is noted in the D0 doc.
Suite: clean-room complete solution 11,271 passed / 4 skipped / 0
failed; Core assembly re-run green after the review-driven test
hardening.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The synthetic fixtures reproduce our stuck fingerprint while faithfully
executing the documented control flow; ACE's independent port shows
EdgeSlide reachable only via the OK arm's step-down failure. Together
they force the sharper question: retail's insert returns OK per tick
where ours returns Adjusted. The round-2 cdb script (stack samples on
edge_slide/cliff_slide/step_down + a step_down counter round 1 never
had) carries falsifiable predictions written down BEFORE the capture.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Third attempt, landed on evidence where the first two correctly refused:
the ten-run stability gate passed 10/10 bit-identical (0x42667451, two
clean-room cycles among the runs), the recalibrated golden's every value
measured with derivations rather than guessed, and the historical
measurement flip stands recorded as unexplained-but-unreproducible
after 37 hunt runs plus these 10 found no divergence anywhere.
The mechanism, completing the S4b byte-pin: validate_walkable plants
the sphere at perpendicular r*N.z (byte-faithful, untouched); this push
fires once per settle and lifts to tangent equilibrium dist=r, where
the trigger goes quiet — retail's slope hover, arriving via the push
exactly as the original substitution's own comment predicted retail
had. Sabotage: restoring radius*N.z reddens the discriminating
exact-value test verbatim. AD-65 conformance, the uphill no-flap
guard, and the #331 absorb pin all green untouched.
AD-66 retired (the campaign's last withheld row); AD-69's seam-frame
correction deliberately unbundled, stays active as its own follow-up.
Clean-room suite 11,267 / 4 / 0 — the suite's two AD-66 skips are gone.
User's "port the retail pair" decision is now fully executed; the
hover-look slope gate is the remaining acceptance.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Retail's per-tick edge-family entry (594 lockstep firings, zero
step_up) is the constraint any pseudocode reading must reproduce — the
prior 'retries from scratch, retail-identical' conclusion is refuted by
the live profile, so D0's job is finding the branch that reading
missed, with the step-down-phase-ordering hypothesis named as a
candidate to verify rather than assume. Trap list absolute: the entire
proven-faithful response machinery is off-limits; the fix is routing
fidelity only.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
cdb on the PDB-paired retail client during the user's 45-degree glide:
edge_slide/cliff_slide 594 each in lockstep, set_sliding_normal 538,
step_up ZERO. Ours: 18 edge-family firings total, stuck ticks
dead-looping on insert retries. The divergent branch is
transitional_insert's handling of the refused steep walkable — retail
proceeds into the step-down-failed/edge path per tick, we retry from
scratch. The D0 code-reading's 'retries from scratch, retail-identical'
conclusion is corrected by the runtime evidence: the profile is what
the decomp reading could not see.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The user's side-by-side is the axiom: retail glides laterally along the
steep hillside, faster with more approach angle; acdream stops dead.
D0's line-by-line faithfulness of the response path relocates the
divergence upstream — the request shape reaching the transition, the
broadphase question, or state seeding. Next: the cdb live-trace session
at the slope, per the workflow's Step -1.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Five links traced from the named decomp: the below-push never executes
(OnWalkable guard — the probe printed the wrong guard pair), the
from-scratch retry is retail-identical, and validate_transition's
failure path manufactures every captured field including the (0,0,1)
default. Stopping dead may simply BE retail. Two validations remain:
the user observing their RETAIL client at a comparable slope (the
cheapest decisive test there is), and — only if retail visibly
slides — the find_cell_list broadphase question via the cdb toolchain.
The mechanism paragraph's wrong-cause framing is retained and
corrected in place, per the register's own honesty pattern.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The trace shows 275 uniform player stuck ticks: target 0.268m below a
just-too-steep plane (N.z=0.599 vs FloorZ 0.664), below-push Adjusted
on every transition attempt with the IDENTICAL dist each time — the
adjust is recomputed from scratch instead of feeding the next attempt,
attempts exhaust, the tick yields zero. The fix contract's question is
now a single retail cross-read: does transitional_insert feed the
adjusted CheckPos forward, or branch differently on Adjusted-with-
non-walkable. The probe paid for itself in one one-minute run.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fires only on the stuck-tick predicate (>=1mm XY requested, <=0.1mm
achieved), buffering per-tick phase outcomes cheaply and flushing only
on a stuck tick: per-insert-attempt phase/state/normal/source, step-up
enter/exit verdicts, every ValidateWalkable branch with dist/waterDepth
and both SetCollisionNormal guards evaluated, and the tick's final
AdjustOffset pair. Zero cost when off (flag before any allocation — the
I1 zero-alloc gate stays green), mover id on every line, [ThreadStatic]
buffer per the referee-safety rule. Two tests: fires on a synthetic
wall-stuck tick, silent on ordinary movement.
Diagnostics only; no behavioral change. Suite 11,271 / 6 / 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Root cause pinned by IL-decompiling Silk.NET.Windowing.Common:
ViewImplementationBase._inRenderLoop is set at DoRender/DoUpdate entry
and cleared ONLY on normal return, so a throwing frame callback leaves
it armed forever and any later Dispose -> Reset throws "You cannot call
Reset inside of the render loop", exit 82, replacing the original
wounding exception in the report.
The fix mirrors Silk's own bracket exactly: GameWindow._renderLoopArmed
set at OnUpdate/OnRender entry, cleared only on their normal return —
deliberately NOT in a finally, so it tracks the wound the same way
Silk's private field does. ReleaseNativeWindow checks it before
disposing: armed -> best-effort Close() (swallowed so it can never
become the reported failure), no Dispose, and a new terminal status
CompleteWithDeferredNativeRelease with Error kept null — the original
exception stays the primary report. Healthy paths (OnClosing's
in-loop completion, Run()'s tail release) are byte-unchanged, and the
new PublishNativeWindow parameters default to null so every existing
caller and test behaves identically.
Sabotage: disabling the armed-check flipped the deferral test to
Expected CompleteWithDeferredNativeRelease / Actual Complete —
the guard is what the test exercises. Clean-room suite 11,262 / 6 / 1,
the 1 being #340's documented load flake (passed standalone; second
recorded firing noted in its entry).
Queue: #344 done, #343 done; next #345's instrumented mechanism
session, then #341's boundary hunt.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
During a portal transit the two world-frame owners legitimately rebase
on different edges (Runtime at TeleportAdvanced, streaming only after
old-window retirement), and a spawn projection landing in that window
hit the #283 invariant as an unhandled render-path throw — the crash
the user hit entering a dungeon.
The guard's check is unchanged; only the disagreement RESPONSE is
discriminated on the canonical transit authority
(RuntimeWorldTransitState.IsTeleportActive, the same field the App
layer already reads for portal-in-flight): in transit -> the
materializer's existing "not yet" return, parking the projection on
its established retry rides (OnLandblockLoaded's re-attempt loop,
whose ordering guarantees agreement on retry because the recenter
coordinator adopts the new origin BEFORE unblocking new landblock
loads — verified at source; plus OnPosition recovery and
OnAppearance). Outside transit -> still throws: genuine corruption
stays loud. The implementer explicitly ruled out riding the Runtime
placement pump, which would have acknowledged-and-discarded the
completion receipt and silently dropped the entity forever.
Sabotage: removing the discriminator reddened the pre-existing #283
throw tests as well as the new not-in-transit test — the sabotage
defeats the original contract, not merely the new coverage. Four new
tests cover defer, defer-then-agree-then-succeed (projected exactly
once), throw-outside-transit, and the agreeing pass-through.
Clean-room suite: 11,261 passed / 6 skipped / 0 failed. #346 filed for
a sixth, distinct load-sensitive allocation flake observed during the
runs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The probe design: fires only on the stuck-tick predicate (zero XY yield
against nonzero request), printing per-attempt phase outcomes, step-up/
step-down verdicts, every ValidateWalkable branch with its flag guards
at the SetCollisionNormal site, and the AdjustOffset pair — the
collN=(0,0,1) fingerprint's producer names itself. Trap inventory from
the week's do-not-retry ledger attached so the eventual fix cannot
stumble into a retail-faithful mechanism.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The pre-campaign binary shows the identical 0.0%-yield signature across
eleven consecutive sustained-input windows at the same slope with the
same protocol. Older defect, newly noticed. The throwaway A/B worktree
is removed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The user's report measured two ways: the branch probe shows the steep
face reached only 18 times with a HEALTHY applied cliff-slide (the #32
retention visibly working — lastN is the flat ground, not the face);
the flight recorder shows the actual stop is 838 uniform ticks on
nearly-flat approach terrain eating 100% of angled input with
collisionNormal straight UP and no sliding latch. Not the absorb, not
the slide family. Regression status unknown — the A/B against the
pre-campaign binary is mandated as step 1 before any theory.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Retail floods at authored size regardless of placed scale, which
under-registers ENLARGED objects into neighbouring cells (a
walk-through edge case retail genuinely has). The user chose not to
import that bug for byte-fidelity. Closed; not a cleanup candidate.
This was the campaign's last open decision.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
AP-83/AP-91 claimed no current mover sets PerfectClip. The containment
proof found the opposite and the contract's honest-fallback fired: the
camera probe (the sole production setter) reaches BOTH ACE-derived
tails live — the viewer exemption is creature-only, the shadow-list
walk is unconditional, and static scenery with authored primitives is
a real non-creature population. Every reach is now recorded
(camera-live silently; any non-viewer mover loudly, one-shot), so a
future flag change cannot exercise unreviewed ACE-derived math
silently. Four tests drive the camera's exact call shape both ways;
the sabotage was intelligently adapted — there was no existing cut to
disable, so it flips the one axis the proof depends on (IsCreature)
and asserts reachability inverts. Both register rows rewritten
CONTAINED-not-dormant with severity narrowed to camera-feel (the probe
never commits a PhysicsBody).
Landing note: diagnostics-only diff (two guard calls + counters +
corrected stale comments), verified directly by the session lead
rather than a review cycle — the review budget went where behaviour
changed tonight.
Campaign S CLOSES with this landing: S1A/S1B/S2/S4/S5/S6 done, S3
cancelled, three user-passed gates, one honestly-open item — AD-66's
reland, twice self-refused by its own stability gate, blocked on the
#341 codegen-shape measurement instability whose ABA evidence and
first discriminating experiment are filed.
Clean-room suite: 11,257 passed / 6 skipped / 0 failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The reland's ten-run protocol was stable-with-lift, then the
recalibrated form flipped on its first run, and a same-binary ABA
isolated it to assertion CODE SHAPE alone with a hardcoded-constant
control killing the property-read hypothesis. Best remaining story:
JIT inlining/tiering of the settle chain differs by caller IL shape and
some computation in it sits on an exact float boundary deciding WHEN
the one-time lift happens. First discriminating experiment for the
investigation: DOTNET_TieredCompilation=0. The stop clause fired twice;
production is unchanged; no third reland before the boundary is found.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The contract's D0 byte-pin refuted the tangent-placement premise:
validate_walkable @0x0050d010 is planted for every normal mover
(Ghidra + BN + ACE agree; ours is already byte-faithful; only the
camera branch is tangent). With the byte-proven bare-radius push-out
the coherent retail mechanism is plant-then-lift — the push fires once
per settle, raises the body to tangent equilibrium, and both checks go
quiet there. The slope float comes from the push, not the placement,
exactly as the original substitution's own comment argued. The 84%
live fire rate was measured against our push-disabled steady state;
the #341 assert-shape flip now reads as order-dependent settle state.
The user's 'port the retail pair' therefore maps to relanding AD-66's
bare radius alone, with the ten-run stability protocol.
The STOP rule paid for itself: an implementer without it would have
made ValidateWalkable DIVERGE from retail in the name of faithfulness.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The reveal hang (three live occurrences: portal-space, login twice) was
an unhandled OverflowException on the render frame's readiness
evaluation: EnsureRenderDataReady found a packed 64-bit EnvCell geometry
id OWNED but DESCRIPTOR-LESS — the release path removes the descriptor
while render data parks on the LRU, IncrementRefCount restores ownership
on a revisit, and the scheduler's PrepareEnvCellGeomMeshDataAsync has
not yet re-registered — and fell through to the Setup/GfxObj arm, whose
checked((uint)id) cast threw. After that the reveal was never evaluated
again.
The fix corrects the TYPE DISPATCH rather than suppressing anything:
packed ids (bit 33, GetEnvCellGeomId) answer "not yet" in the
acquire-to-prepare window — the true answer, since the scheduler
re-registers on the same landblock build — and PrepareMeshDataAsync's
blind cast becomes a typed, loud invariant failure naming the id kind
and the issue, so a future caller repeating the confusion gets a
diagnosis instead of three live hangs.
Validation: the crash was DETERMINISTIC at login cell 0xA8B4002F (two
consecutive hard failures); with the fix the same login revealed
cleanly and a full session — 16,585 entities, five portal generations,
the user-passed Session-B dungeon gate — ran with zero overflows and
zero guard fires. Clean-room suite: 11,253 passed / 6 skipped /
0 failed.
Found because the Session-B gate launch finally captured the stack the
earlier #339 hangs never printed. #343 (the wounded loop's Reset-in-
render-loop shutdown) and #344 (the mid-teleport world-frame race the
same evening surfaced) are filed separately and unfixed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
S1B + S2's shared gate passed live ('Feels good!'), run INSIDE the
dungeon the #344 crash had conveniently saved the character at. #344
filed with its two halves kept separate: the world-frame guard is
CORRECT (it refused a 28.8 km mis-projection — do not weaken it); the
defect is the teleport-recentre ordering race plus the unhandled-crash
failure mode where a deferred retry belongs. AD-64/#324 family.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Session-B gate launch reproduced the reveal hang at login and the
log finally carries the crash: PrepareMeshDataAsync's Setup/GfxObj arm
does checked((uint)id) on a packed 64-bit EnvCell geometry id, reached
because EnsureRenderDataReady found the id OWNED but DESCRIPTOR-LESS —
the release path removes the descriptor while render data parks on the
LRU, re-acquire restores ownership, and nothing re-registers the
descriptor until a full prepare is re-requested. The OverflowException
rides the render frame's readiness evaluation and the reveal is never
evaluated again. Demote-then-revisit ordering explains the
intermittency. S1B/S2 are not implicated: neither touches this
pipeline and the signature predates both.
Root-cause fix deliberately NOT designed in this session's tail — the
invariant to restore is owned-implies-descriptor (or a legitimate
re-request from retained data), which needs the acquire-path read the
next session starts with. Catch-and-continue and skip-64-bit-ids are
named as the band-aids they are.
#343 filed alongside: the wounded render loop's shutdown throws Silk's
Reset-inside-render-loop and abandons incomplete, exit 82.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both static sites (LandblockPhysicsPublisher, the headless-only
LandblockPhysicsContentBuilder) emitted an authored Setup Sphere as a
base-anchored Cylinder of radius r and height 2r. The live path emits a
Sphere for the same data, so the same object collided differently by
arrival route, and the narrow phase met a flat cap where retail meets a
curved surface. Both sites now mirror ShadowShapeBuilder.FromSetup's
Sphere block exactly.
Combined Opus review: PASS. Its numeric verification of the dispatch
test's geometry (head-sphere clearance 0.201 m for the true sphere; the
cylinder counterfactual inside by 0.10 m XY with the Z band overlapping)
is what makes the discrimination claim more than a sabotage anecdote,
and its F8 finding is applied: the test now carries a POSITIVE control —
aiming straight through the boulder's centre must block — so a
membership/seed regression can no longer masquerade as a curve-hit
pass. F4 applied: CylHeight is asserted, not inferred (the C4 lesson).
F3 applied: the deleted Quaternion.Inverse base composition is recorded
as internally coherent for the old cylinder's world-Z axis — the defect
was the shape TYPE, not that rotation math.
The review also verified the deleted-cylinder blast radius: the F2
overlay's drawn span is IDENTICAL for both shapes (old [c-r, c+r], new
[c-r, c+r]); the flood sphere's centre rises by exactly r, which cannot
change outdoor membership (XY rectangle) and lands the indoor half on
Session B's dungeon gate alongside S1B; and the sphere-branch flood is
now pinned uncapped by a genuine eleventh-shape A/B test.
PublishStaticCollision — the headless static path — gains its first
test ever.
AP-155 is NARROWED, not deleted (review F13): the has-BSP source split
(entity.MeshRefs vs setup.Parts + AnimPartChanged) survives and keeps
the row active. The shared-primitive-emitter refactor that would make
route independence a compile-time property is the filed follow-up
(review F20).
Population: 3,506 of 5,935 installed Setups, structurally equal to
AP-157's third-branch count (byte-identical classifier — three
independent routes agree: 3,605 - 99 = 3,506).
Clean-room suite at implementation: 11,253 passed / 6 skipped / 0
failed on landed S1B. Post-review-hardening: Publisher tests 25/25,
Content tests 2/2, both green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CellTransit.FindTransitCellsBox ports CEnvCell::find_transit_cells'
part-array overload @0x0052cae0 line-for-line: per-portal x per-part
order, the sphere cheap-reject at F_EPSILON+radius, the box admit whose
"Straddle or crossing-side" rule is exactly retail's `eax != side` under
the PDB Sidedness enum, leads-outside placed AFTER the admit, the
unconditional unloaded-neighbour hint without the sphere overload's
re-test, the destination box_intersects_cell gate with its deliberate
no-break, and add_all_outside_cells after the loop. The box-vs-cell BSP
traversal lands in BOTH representations behind the flat-authoritative
dispatcher with a graph referee whose 20,000 installed comparisons are
pinned by assertion (review F5), zero mismatch.
Dual Opus review: PASS on both lenses. The mandatory D0 pseudocode pass
caught that the contract's own supplementary note misattributed the box
block to the sphere overload — it belongs to a SECOND
check_building_transit overload @0x0052c680, whose portal-side
convention is INVERTED and whose admit differs; the pseudocode doc now
records that trap plus two byte confirmations made at review:
which_side @0x00444720 is strictly > eps for POSITIVE, and
intersect_box's in-plane early exit returns CROSSING(3)
(jp @0x005aa1bc -> mov eax,3), settling review items b1/b2 for the
future bridge porter. The bridge itself stays unported as AP-159's
explicit remainder.
The review also retired #335's severity premise honestly: "over-
inclusive only, never a missed one" is wrong at production shape ratios,
where the box (whole-vertex AABB) legitimately exceeds the sphere
(physics-polygon root sphere). Measured, both populations: rigged
(box << sphere) — 1,520 placements, 978 cells removed, 0 added;
production-ratio (box >= sphere) — 950 placements, 20 removed, 1 ADDED
through the loaded-neighbour gate, which is retail's direction, not a
defect. The no-op guard (review F4) asserts removal is nonzero so an
unwired admit cannot pass silently.
Process note: the implementer authored against this session's worktree
at bec5c69d, 25 commits stale — the recorded worktree-base class. All
six files were byte-identical between bases, the diff transplanted
losslessly, and every verdict-bearing run (referee, direction sweeps,
this clean-room) was re-executed on current main. S2's uncommitted
phase-1 edits were stashed for this landing so the suite verdicts
exactly one changeset.
Also untracks 341-slope-capture.jsonl (an accidental add) and
gitignores it.
Clean-room suite: 11,248 passed / 6 skipped / 0 failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
AP-83/AP-91 claim no current mover sets PerfectClip. False: the camera
probe sets it, faithfully to retail's camera flags. The containment
proof therefore has a real question — whether the viewer exemptions or
the camera sweep's shape cut every chain to the ACE-derived TOI tails —
and an honest fallback if they do not: the rows get rewritten with the
camera named as the live population rather than 'unreachable'. Guard
either way, so a future flag change cannot silently start executing
math nobody re-verified.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The suspect line is pinned at scoping: our ValidateWalkable measures the
sphere's VERTICAL bottom against the plane (planted rest, perp = r*N.z)
while our AdjustSphereToPlane is already a faithful tangent port — we
mix the two geometries today and the planted one wins on terrain. D0
byte-pins retail's validate_walkable distance basis with an explicit
STOP if it refutes the premise; D2 retests the #341 harness flip ten
ways under the mechanism's stability prediction; the gate teaches the
user that slightly hovering feet on steep slopes is the CORRECT retail
look. Queued behind S1B and S2 — one physics slice in flight at a time.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The divide-by-itself artifact in the BN text resolves in Ghidra to
t = (dist -/+ r) / dot(N, stepDir): a ray-vs-plane interpolation to
perpendicular-distance == radius. Two independent decompilers agree on
the mechanism the #341 resolution rests on.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CPolygon::adjust_sphere_to_plane @0x00538210 solves the walkable
placement for perpendicular distance == radius: retail rests the sphere
TANGENT to the slope, which makes its bare-radius push-out trigger
structurally inert — and explains why our planted rest (perp = r*N.z)
makes our substituted trigger inert here by the same algebra. Each
engine's trigger matches its own resting geometry; the live A/B's 84%
fire rate is what mixing retail's trigger with our placement produces.
AD-66 is therefore not a standalone row: the faithful unit is the pair
(tangent placement + bare trigger), ported together or divergent
together. The visible corollary of the retail pair — feet floating by
r*(sec(theta)-1) on slopes, up to ~20 cm near the walkable limit — is
why the decision is queued for the user's eyes rather than taken
silently under the retail-first default.
From capture to mechanism in one morning: the user's two-minute slope
run plus one decomp read did what the overnight harness could not.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
3,870 player resolves captured at Rithwic with ACDREAM_CAPTURE_RESOLVE.
Of 2,955 contact-seeded ticks the RETAINED trigger fired zero times —
the current push-out is inert in ordinary play — while retail's bare
trigger would have fired on 2,471 (84%), lifts 2-88 mm, p50 27 mm. The
body rests at r*N.z every grounded tick, so landing AD-66 alone would
engage the push on virtually every slope step and fight the foot
planting: the oscillation the original substitution was written against.
The exposed question sits upstream: our placement plants the sphere
vertically (perpendicular r*N.z); if retail's walkable contact rests
tangent (perpendicular r), retail's bare trigger is inert in retail
exactly as ours is here, and the trigger cannot be ported without the
placement geometry. That retail-side verification is now the apparatus
session's first task, and it also gives the harness flip a mechanical
suspect: settle-state order dependence, not physics.
Measured from the user's own two-minute run rather than argued — the
cheapest decisive instrument of the campaign so far.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The six-part sitting ran 2026-08-07 on the post-S4 binary: nine-stop
tour, portal repetition, the cancelled-teleport vanish-and-return,
equipped-item teleport, two-client observation, and the fresh-process
logout/relaunch cycle. Log evidence: 19 reveal generations, 17
materializations, one cancel correctly superseded by its immediate
replacement, zero hangs, zero wait-cues. The graceful-logout path
cleared the ACE session instantly and login returned to the exact last
location.
Recorded honestly rather than roundly: the placement probe families were
not armed during the sitting, so route-7's thin cause=propagate evidence
was not thickened and 4b-3's cause=cellless case remains unexercised
with an unestablished trigger. Both close as user-passed with thin log
evidence; the symptom-side gates are the regression net.
The probe-family strip is unblocked and queued behind the in-flight
Campaign S slice for build-slot reasons.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
'Slopes feels good' at the downhill/diagonal/jump-landing gate, on a
launch whose capture self-verified the binary identity and the authored
step heights. The overnight program's one owed visual gate is closed;
Campaign S continues at S1B.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Campaign S slice S4, the half that landed. Retail's CTransition::
adjust_offset @0x0050a370 branches on dot(offset, contactPlane.N) at
0x0050a4fa: moving INTO the plane subtracts the normal component
(0x0050a529), moving AWAY calls Plane::snap_to_plane @0x00509c50 —
which preserves X and Y and re-solves ONLY Z so the offset lies in the
plane (the d terms cancel algebraically), no-op under the
0.000199999995f |N.z| epsilon. acdream ran the orthogonal projection in
BOTH directions, shrinking downhill XY travel by cos^2(theta): 25% at
30 degrees, 50% at 45 — AD-65's recorded shortfall, now retired.
The combined Opus review independently re-derived the algebra, the
branch polarity, the epsilon's bit-identity (17b75139), and the
sabotage magnitude (the re-instated projection yields X = 0.75 =
cos^2 30 exactly), and verified the delta is 4 non-comment lines with
the into-plane arm, the crease arm, and both no-plane arms untouched.
Its blast-radius sweep found the away arm exercised but NOT
discriminated by any pre-existing test — every one asserts lower
bounds the snap over-satisfies — so the two new exact-value tests are
the only discriminating coverage, recorded in the test's class doc,
and the felt 33-100% downhill speed-up is the morning gate's one row.
AD-66 (the push-out's bare radius) is WITHHELD: byte-confirmed twice,
implemented, then pulled after the same clean-room binaries measured
contradictory absorbed-tick outcomes flipping with nothing but test
assert shape — issue #341 carries the observation matrix and the
apparatus plan; its two exact-value tests are [Skip]-ed; the retained
substitution's rationale is restored at the site per review F1, with
the review's remaining findings (F2/F3/F4/F5/F6) applied and F8 filed
as #342. AD-69 filed: the same block omits retail's get_block_offset
seam-frame correction, deferred to the AD-66 relanding for
attributability. #340 filed: a fifth load-sensitive flake.
Review verdict: PASS. AD-65 is provably unable to reach the #341
anomaly's code path (the absorb scenario takes the crease arm).
Clean-room suite: 11,239 passed / 6 skipped / 0 failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The slice text claimed a door's collision stays where the shut door was.
AP-84's own row refutes the scenario: an open door is ETHEREAL (#150)
and bypasses collision entirely, so a door only ever collides in its
registered default pose — the approximation is behaviourally equivalent
for the entire known BSP-part weenie population, and the row's risk
column already carries the revisit trigger for animated non-ethereal
BSP movers if they ever appear.
The campaign's governing lesson says register rows are leads that must
be re-measured; the dual of that is that a CORRECT row must not be
'fixed'. The plan itself committed the reading failure this time. No
fix, no gate, no door row in the morning sitting.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
LiveEntityCollisionBuilder and LiveEntityDefaultPoseResolver move from
AcDream.App.Physics to AcDream.Runtime.Physics with no behaviour change
— diff-verified byte-identical shape math by both review lenses. The
Build signature's App-record parameter is replaced by presentation-free
primitives with identical guard semantics, INCLUDING the
FinalPhysicsState read the contract had missed and the implementer
surfaced rather than dropped. Visibility stays internal: Runtime's
existing InternalsVisibleTo grants already cover every consumer, so the
implementation's public widening is reverted per the architecture
review's finding 11.
The registration WIRING is deliberately WITHHELD. Both Opus lenses
failed it, converging: a shadow registered at spawn freezes there
(RuntimeRemotePhysicsUpdater is Runtime-homed but App-driven — nothing
headless ticks it), so a walking NPC becomes a phantom obstacle at its
spawn point while the real NPC still passes through the bot; three of
five shadow-lifetime edges leaked (pickup leaves a permanent invisible
collider, supersession orphans a duplicate, generation reset never
unregisters and the K-ledger convergence oracle only checks retained
shadows AFTER disposal clears them); and headless cannot resolve BSP
collision assets at all, so doors and chests would still be
walk-through. The frozen-shadow root was the SESSION LEAD's contract
error (fact 3), not the implementer's.
#330 stays OPEN, rewritten as the seven-point scope map the reviews
produced — the honest overnight deliverable is that map, not a
half-mechanism carrying new divergences.
Suite 11,235 passed / 4 skipped / 0 failed (the withheld seam's two
tests account for the delta from the implementation run's 11,237).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Applying feedback_register_revert_resurrection to its own discovery:
auditing every row in a8a7d64b's register hunk found the inverse defect
in the same diff. The revert resurrected retired AD-55 AND deleted
then-active AD-56 (the plumb-fall freeze adaptation, split out of TS-4
by the commit being reverted). Nothing ever restored it, and its
condition came back to life when TS-4's Path-6 shortcut deletion
re-landed for real at Slice 2B — so for eight days a live, test-pinned,
deliberately-documented behaviour had no register row, which is 'a bug
twice over' by the register's own rule.
Restored verbatim from 5e2be19b with a provenance header. One revert,
two silent register corruptions in opposite directions — the memory
rule's audit step is not optional. AD section 50 -> 51.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Sledding constant has been the byte-confirmed cos(10 deg) =
0.98480775f in production since 252e8068 (2026-07-30), which also struck
the register row. Five hours later a8a7d64b — reverting the UNRELATED
TS-4 commit 5e2be19b — restored this file's older hunk and resurrected
the un-struck row text while leaving the code fixed. The zombie row then
cost tonight's session a full duplicate byte-derivation: the stale row
said 0.99999536f was live, so the binary was re-read to prove what
252e8068's own commit message already contained verbatim.
Tonight's derivation note is corrected to what it actually is — an
independent confirmation of the week-old fix — and S5 collapses to this
bookkeeping: no code change, no feel gate owed; the user has been
playing on the fixed constant for a week.
Process rule filed to memory (feedback_register_revert_resurrection):
after any revert whose diff touches the register, re-verify EVERY row in
the touched hunks; and before acting on a row's 'our code does X' claim,
grep the cited file for the claimed expression first. AD section
51 -> 50 active rows.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both static publication paths emit an authored Setup Sphere as a
base-anchored Cylinder (r, 2r) while the live path emits a Sphere for
the same data — different narrow-phase dispatch and a route-dependent
collision difference for the same object. The fix mirrors FromSetup's
step-3 emission at both sites; the contract's first test is the
route-independence property asserted shape-for-shape, and the dispatch
test picks geometry where cap-hit and curve-hit verdicts differ so the
sphere verdict is observable, not inferred.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
snap_to_plane @0x00509c50 semantics extracted at scoping: XY preserved,
Z re-solved so the offset lies in the plane, no-op under the 0.0002
|N.z| epsilon — versus our orthogonal projection, which is exactly the
cos-squared downhill shortfall AD-65 recorded. Branch polarity pinned
from the test ah,0x41 idiom at 0x0050a4fa: into-plane subtracts,
away-from-plane snaps.
AD-66's port carries a mandatory regression scenario: the original
substitution was empirically motivated (uphill contact-flap), so the
contract requires that exact scenario as a test and a full STOP if the
faithful port genuinely reds it — re-filing the divergence as deliberate
is the session lead's call, not the implementer's tune.
Priority note: S4 jumps ahead of S1B in the overnight queue on felt
value — 25-50% downhill XY shortfall is daily-feel, while S1B's
over-inclusion is zero-felt fidelity. The campaign's
membership-before-query ordering is about masking, and an over-inclusive
residual masks nothing downstream.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Campaign S S1A, both outcomes the measure-first rule exists for.
AP-157's CylHeight half is RETIRED as a non-divergence: retail's own
cylsphere overload (CObjCell::find_cell_list @0x0052b9f0) copies
localtoglobal(low_pt) + radius per cylsphere, capped at 10, and never
reads height — retail collapses a cylsphere to a base-point sphere
exactly as acdream does.
The sorting-sphere half measured REAL against retail's registration set
— 1,812 of 3,343 evaluated Setups (54%) fail containment at 1 mm, worst
shortfall 18.135 m — and then PROVEN collision-unreachable: for this
branch the flood spheres and the collision-test geometry are the same
per-part Sphere list, so every omitted cell is one the entity's test
geometry cannot reach, and retail's wider sorting-sphere registrations
are narrow-phase rejects on retail too. Fix deferred to the next
bake-schema revision rather than performing Slice I3 surgery for zero
behavioural delta. The measurement test stays in the tree as the
permanent record (population cross-checked against the dispatch test's
independently-committed constants: 3,506 = 3,605 - 99).
AD-55 is byte-decoded and RESOLVED against our constant: the binary
loads qword [0x007c6b28] = pi/18 exactly and executes FCOS — retail's
Sledding flatness threshold is cos(10 deg) = 0.984808. Our 0.99999536f
is cos(0.17453 DEGREES): the radian literal misread as degrees, which
makes the object-friction arm unreachable on real terrain (nothing is
flatter than 0.175 deg). Evidence note carries the full instruction
listing and the polarity of the test ah,0x41 / jp idiom; the one-line
fix + conformance test is S5, queued behind the running implementation
slice for build-slot reasons.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Campaign S night shift. #330's contract pins the scoping facts so the
implementer inherits measurements instead of re-deriving them: the
builder is already presentation-free logic (the only App coupling is one
identity-guard parameter), headless has full content (_content.Dats +
prepared PhysicsDataCache), shadow-sync already runs in Runtime once a
shadow exists, and the no-window inbound route is host-disjoint per
AD-64 so registration wired there cannot double-register on the
graphical host. The local-player ProvenShapeless pin is explicitly OUT —
fixing it blind risks the K-series gates.
AP-159's S1B contract maps the pseudo-C line ranges for the part-array
find_transit_cells overload, the box-vs-cell BSP traversal, and flags
the adjacent overload's Binary Ninja signature artifact for the
mandatory pseudocode step to resolve. House rule carried: a new
traversal in two representations ships with an exact differential
referee, and the direction assertion (membership strictly shrinks) is a
test, not an assumption.
Also settled at scoping, evidence in the S1A brief: AP-157's CylHeight
half is a NON-divergence — CObjCell::find_cell_list's cylsphere overload
@0x0052b9f0 copies localtoglobal(low_pt) + radius per cylsphere, capped
at 10, and never reads height; acdream's base-point cylinder flood is
exactly retail's behaviour. Register correction follows with S1A's
measurement numbers.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The three-site probe answered it in one run: prepare and publish carry
the authored 0.600/1.500 to the publication candidate, and resolve
receives exactly those values for the entire session after one early
0.400 reading. Re-reading the ORIGINAL 337-support.log with statistics
instead of an eyeball: authored pair 111,248 lines, 0.400 pair 358. The
filing was built on an early line of a 255k-line capture; the alleged
mechanism (values never wired to the mover) does not exist.
The 358 are AD-68, now registered: GetSetupMoverShape's placeholder
(empty spheres -> legacy capsule, 0.4/0.4 steps) during an entity's
async Setup-residency window, plus the local player's own seconds-long
window between controller construction and publication-candidate
adoption. Retail loads synchronously and has no such window. Left as-is
deliberately: shrinking it is streaming work.
The filing still paid for itself: three false doc-comment claims
corrected in PlayerMovementController (retail '~0.4 m' twice, and an
ApplyStepHeights writer that never existed anywhere in the tree —
replaced with the real writer chain), retail's actual fallback pinned at
0.04 (CTransition::step_up @0x0050b655), and the resolve probe now
prints the mover id, because the early 0.400 was most plausibly a
REMOTE player — remotes also carry IsPlayer — and the guid rule
(feedback_probe_identity_attribution) exists precisely to stop that
misread.
No production behaviour changed; nothing for the morning gate. AD
section 50 -> 51. Suite 11,234 / 4 / 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both halves of #32 are now closed: remote at 204d0ae0 (user-passed
2026-08-04), local at 332045c7 (user-passed 2026-08-07 at the Rithwic
cliff, on the first launch whose capture printed the fixed binary's own
assembly path). The research doc carries the outcome banner: the live
capture landed in decision-table row 1 verbatim and Section 7's fix
shipped unchanged.
AD-67 records the one deliberate residual: the narrowed SetContactPlane
still writes ContactPlaneCellId, which retail writes only at
init_contact_plane (0x0050e8ca). Kept on the research doc's own advice —
our consumers want the current value — and not bundled into the fix
commit, where a second behaviour change would have made the user's
cliff gate ambiguous. AD section 49 -> 50.
Section 3.5's blast-radius items stay open as watch items, now strictly
MORE reachable than before the fix (last-known validity is narrower, so
the StopVelocity recovery and phase-3 reset take their invalid branches
more often). Carried onto Campaign S slice S4.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two checkouts, one relative launch path. Edits and builds ran in the
main repo; every client launch ran from a PowerShell shell whose cwd was
still the session worktree, so 'dotnet run --project src\AcDream.App\...'
executed the worktree's 08-06 22:35 binary — #333 present, #32 fix,
InitContactPlane and every #338 probe absent. Byte-proof both ways: 0
occurrences of the fix strings in the worktree's Core.dll, both present
in the main repo's.
Everything the previous entry concluded is therefore void: the
byte-identical capture was the OLD code re-running (expected), the three
probe silences were one fact (the instrumented binary never ran), and
the 26,358-write attribution table is pre-fix baseline data of the old
binary only. #32's fix returns to UNTESTED, with no evidence against it.
The verification that was supposed to catch this confirmed the wrong
binary: the DLL byte-check ran against the OTHER checkout's bin. So the
self-report now prints typeof(PhysicsDiagnostics).Assembly.Location as
its second line — binary identity becomes a recorded fact inside every
capture instead of an inference from file timestamps afterwards. Memory
updated with the multi-checkout rule: absolute launch paths, verify each
shell's cwd before the first launch.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The 332045c7 fix produced a byte-identical live capture at Rithwic: same
six events, same curN == lastN, same apply=False. The user still falls
through. The commit is not wrong and is not reverted — it restores
retail's setter split and is sabotage-verified — but it closes a writer
that is not the operative one here.
Caller-attributed capture puts 26,358 last-known writes in
PhysicsEngine.ResolveWithTransition, whose only such writes are
check_contact's FAILURE branch seeding from body.ContactPlane. Recorded
with the loop that suggests (2041-2044 seeds ci from body; 2168-2174
writes body from ci) explicitly marked NOT PROVEN, because it rests on
line-number mapping from an optimised build where inlining makes
attribution approximate.
Filed with a BLOCKER at the top: an unconditional one-shot WriteLine at
the head of that same method printed zero times. Both facts cannot hold.
Three conclusions were drawn from probe silence this session and all
three were premature, so the entry says to settle assembly identity with
a check that cannot be explained away before any further work on #32 or
#338 — rather than let a fourth inference compound the first three.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Captured live 2026-08-07 with the raw log attached rather than
summarised. Generation 2 to cell 0x3032001C: render, composites and
collision are all False at begin and still all False at cancel, so
complete and world-visible never fire and the five-second wait cue sits
there until the client is closed.
Filed rather than chased, per user direction. Two things recorded
because they will otherwise be assumed: the same destination succeeded
TWICE in the previous session, so it is intermittent rather than a
broken landblock; and while it is mechanically very likely unrelated to
the #32 contact-plane change landed minutes earlier (different
subsystem, different thread), it fired on the first run after it, so the
entry says to A/B against a binary without #32 before ruling it out
rather than asserting independence.
Also flags, without assuming either way, that #280's D-1 was an
unrecoverable portal hang with the same visible symptom — this is either
that regressing or a second mechanism wearing its face.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Measured live at Rithwic 2026-08-06 with ACDREAM_DUMP_EDGE_SLIDE=1.
Six branch2/steep-cliffslide events, every one reporting
curN=(-0.954,0.000,0.301) lastN=(-0.954,0.000,0.301) angle=0.0000
apply=False, outcome degenerate-cross/last-known. That is decision-table
row 1 of the research doc, verbatim.
CTransition::cliff_slide @0x0050a6d0 takes its slide direction from
cross(steep contact normal, last_known_contact_plane.N) — it needs the
surface the mover was STANDING ON as the second vector. acdream's
CollisionInfo.SetContactPlane latched the last-known group on every
call, so by the time cliff_slide ran, last-known had already been
overwritten with the steep face itself: the cross product of a vector
with itself, which is zero. Degenerate direction, no slide, walk off
the cliff.
Retail's COLLISIONINFO::set_contact_plane @0x00509d80 is 22 bytes and
writes the CONTACT group only; the last-known group has four writers,
none of them that function. So the four writes are DELETED and a new
InitContactPlane mirrors CTransition::init_contact_plane @0x0050e850,
writing both — the start-of-transition seed, where there is no earlier
surface to remember. Only check_contact's SUCCESS branch calls it. The
other eleven call sites keep the narrowed setter. This is a port, not a
suppression: no guard, no grace period, no flag.
The user's own A/B was the discriminator: Neftet's block plateaus hold
(188 branch3/precipice-slide events, all before the teleport) while
Rithwic's terrain cliff fails (6 branch2 events, all after). I had
predicted the opposite — that terrain would be the flat-normal case —
and position plus timeline corrected me, not reasoning.
NEW DISCRIMINATING TEST, because the suite had none. It was green both
before and after the production change, so nothing in it defended this
behaviour. Issue32LastKnownContactPlaneTests seeds a walkable plane,
asserts a steep mid-transition contact leaves it intact, and asserts the
resulting cross product is non-degenerate. Sabotage-verified: restore
the four writes and both discriminating rows fail while the
InitContactPlane control keeps passing — the pair separates 'the latch
is gone' from 'nothing writes last-known at all'.
Two existing tests corrected rather than deleted.
PhysicsSetPositionTests.FailedCheck_MapsCollisionHandlerResultToRetailError
passed BECAUSE of the latch (the file the research named); its hook now
populates both groups explicitly, since it asserts report plumbing, not
setter semantics. RetailEdgeResponseOrderingTests.TransitionalInsert_
DegenerateCliffSlideOk_ContinuesOuterRetry was predicted to fail and did
not — it now passes for a DIFFERENT reason (last-known absent rather
than clobbered, which retail also answers with OK_TS). Its comment
described the deleted behaviour and is corrected to say so, and to say
it does not discriminate this fix.
Also repairs the #338 probe. Its first placement in
PlayerMovementController printed nothing across 11,523 live log lines —
the wrong one of two resolve call sites — so it moves to
PhysicsEngine.ResolveWithTransition where every caller passes through,
filtered to the player. The dead site is removed rather than left in
place; a probe that never fires is worse than none. The flag test now
precedes the interpolated string: building it eagerly cost 128 B per
resolve with the probe OFF, which Slice I1's zero-allocation gate caught.
Suite 11,234 passed / 4 skipped / 0 failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The live reading of 0.400 says the controller held its default at that
instant, not why. Two very different causes produce it: the
Setup-derived prepare/publish path never runs for the local player, or
it runs and a later writer clobbers the result. Fixing without knowing
which is a coin flip.
One reading at each hop — prepare (Setup value computed and scaled),
publish (assigned to the controller), resolve (what the resolver is
actually handed) — with a decision table on the flag mapping each
pattern to its cause, including the 0.000 case that would mean a null
Setup took the retail dummy path.
Edge-triggered per site, so the per-tick resolve site prints once per
distinct pair and cannot drown the two one-shot sites it exists to be
compared against. Prepare prints the raw authored pair beside the
scaled one, so a surprise separates wrong-Setup from wrong-scale
without a second run.
Lives in PhysicsDiagnostics per code-structure rule 5 rather than as
per-call-site env reads. Zero cost when off.
Suite 11,231 passed / 4 skipped / 0 failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Report-only investigation of the local player running off cliff edges. No
production or test code changed.
Retail's rim slide is CTransition::cliff_slide (0x0050a6d0), whose direction is
cross(steep contact normal, last_known_contact_plane.N) — it needs the surface
the mover was standing on as its second vector. Disassembly of the PDB-paired
binary shows COLLISIONINFO::set_contact_plane (0x00509d80, 22 bytes) writes only
contact_plane_valid / contact_plane / contact_plane_is_water; the last-known
group has exactly four semantic writers in retail, all outside the per-substep
collision response (CTransition::init_contact_plane 0x0050e850,
init_last_known_contact_plane 0x0050e8e0, the validate_transition tail
0x0050ad07, and the clears).
acdream's CollisionInfo.SetContactPlane latches the last-known group on every
write, at all 13 call sites. The step-down probe's own steep plane therefore
overwrites the ground reference before EdgeSlideAfterStepDownFailed reads it,
CliffSlide's cross product goes to zero, its degenerate OK return displaces
nothing, and TransitionalInsert's retry accepts the candidate hanging over the
drop. The latch dates to 9ea8ae51 (2026-04-13) and was never retail-verified,
matching the user's "pre-existing, not a regression".
Also established: every edge_slide branch, gate and threshold in acdream is
byte-exact against 0x0050b3d0 / 0x0050b812 / 0x0050b886 (the step-down probe
schedule is confirmed against the disassembly, which corrects a Binary Ninja
stack-slot misread at 0x0050b8ba); 204d0ae0 deleted four remote-only forgeries
and never touched the sweep, the edge family or contact-plane maintenance, so
it could not have covered the local player; #134 is the same family with an
unverified 2026-07-09 triage closure; and six claims in #32's own text are
stale at HEAD.
The causal link to the user's cliff is a hypothesis, not a proof — it needs
edge_slide to take branch 2. ACDREAM_DUMP_EDGE_SLIDE=1 already prints the
branch and both normals at the deciding site; the report carries the run
command and a six-row decision table. No code before that run.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The gating question is closed against the decomp rather than reasoned
from our source. CTransition::step_up @0x0050b610 defaults step_up_height
to 0.0399999991f and substitutes object_info.step_up_height when
(state & 2); step_down has the same shape at 0x0050b852 and reads the
authored value unconditionally at 0x0050c232.
Two things fall out. Retail's fallback is 0.04, not 0.4 — our value
matches neither the fallback nor the authored 0.600/1.500. And state
bit 0x2 is OnWalkable, so retail applies the authored height only while
standing on walkable ground. We already port that gate faithfully in
Transition.DoStepUp, including the stepDownHeight = oi.StepUpHeight
assignment that reads oddly but is exactly what retail passes. The gate
is not the defect; only the value fed into it is.
The local player is the only affected population: its controller fields
initialise to 0.4f, while remotes and live entities get Setup-derived
values. The property's doc comment names PlayerModeController.
ApplyStepHeights as the authoritative writer — that method does not
exist anywhere in the tree; the identifier appears once, in the comment.
Deliberately NOT fixed. A real writer does exist further out, and
RuntimeSetPositionMoverPreparation does compute the Setup-derived value,
so the plumbing is there. Whether it runs for the local player or runs
and is overwritten is unproven — the probe reading 0.400 says the
controller held its default, not why. Setting the field without knowing
which path won would be a coin flip.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The previous fix said the local origin/main ref had not been written
since April and was three months stale. It was not stale: a fresh
git fetch origin returned it unchanged at f6275f45, so the REMOTE was
genuinely 412 commits behind and the ref was accurate all along.
The original error was therefore never a caching artefact — it was
reading a lagging mirror as if it were the live remote and concluding
the campaign was unpushed. Recorded that way so the rule is usable:
origin here is a mirror and may lag arbitrarily; measure against
github/main or git branch --contains.
Both remotes are now at parity, 0ce54a5c.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes#333, #334 and #337. The Neftet plateau defect — wedged at the
top, jumps sinking into the mesh, corpses falling through — was one
mechanism presenting as three symptoms: Transition.FindObjCollisionsInCell
measured the mover's distance to a shadow entry's part ORIGIN and compared
it against the BSP ROOT BOUNDING SPHERE's radius, two points 23.556 m apart
for that rock. Geometry deep inside the real sphere was discarded before
BSPQuery ever ran.
Deleted rather than re-centred: retail has no distance pre-filter at all,
disassembled from the PDB-paired binary rather than read from Binary Ninja.
Cell membership is retail's broad phase.
Also lands #334's outdoor extent walk (retail's find_bbox_cell_list),
retires AP-158, files #338, and opens Campaign S for the twelve remaining
collision-domain items — now the active work ahead of the M4 vendor
slices per user direction that physics and collision come first.
The ACDREAM_PROBE_SUPPORT / ACDREAM_WIRE_MESH / ACDREAM_PROBE_REACH probe
families are deliberately retained: C5c's connected gates still owe the
instrumentation they provide.
Gate: clean-room full solution suite at a0690947 with all 43 bin/obj
directories deleted first — 11,231 passed / 4 skipped / 0 failed across
all nine projects. User accepted the fix in live play at Neftet.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The handoff's opening said "Nothing is pushed — the branch does not exist
on the remote, and there are 388+ unpushed commits ahead of origin/main."
All three clauses were wrong, in the direction that would most alarm a
successor into thinking the campaign could be lost.
main and github/main are both d4e956b4, and git branch --contains
7b3e2895 lists main. The campaign was merged and pushed the same day.
The measurement error is recorded because it will recur: this repo has
TWO remotes. github is live; origin (git.snakedesert.se) has a cached
origin/main ref pointing at f6275f45 whose ref file has not been written
since 2026-04-27. main is 412 commits ahead of that three-month-stale
ref, which is where "388+ unpushed" came from. Measuring push state
against origin/* here without checking the ref's age produces a false
alarm every time.
Also opens Campaign S as the ACTIVE work ahead of the remaining M4
vendor slices, per user direction: "I think its better to fix these kind
of things before we add new stuff. Physics and collisions are vital."
Clean-room gate at a0690947, all 43 bin/obj directories deleted first
per the closeout's own rule 4: 11,231 passed / 4 skipped / 0 failed
across all nine projects.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Plans the twelve remaining collision-domain items. Slice boundaries come
from file coupling, not from how the symptoms group: AP-157, the AP-156
residual and AP-159/#335 all edit ShadowObjectRegistry (two of them the
same function), so they are ONE slice against a pinned contract per
feedback_dont_parallelize_coupled_plan_slices.
Ordering is membership-before-query, because we just watched a
membership fix (AP-156) be made invisible by a query-site defect
(AP-158) directly downstream of it.
Every slice opens with a measurement that can cancel it. In this domain
over the last two weeks: AP-155's recorded direction was inverted,
AP-156's risk column was wrong (and that is why #334 hid inside it),
AP-22 described an unreachable branch, and #331's headline claim was
refuted. One row in four was materially wrong about its own population,
direction, or existence.
Two items are deliberately reclassified. AP-83/AP-91 are NOT fixes —
the x87 PerfectClip tails do not decompile, so there is no retail text
to port; the honest deliverable is proving the branch unreachable.
AD-65's "live lead for #269" framing is retracted: #269 closed
2026-07-31 and AD-65's sign is opposite to that symptom.
#32 and #338 are pre-work, not slices — both are blocked on a
measurement far cheaper than the fix, and #32 is the outstanding half of
the user's original two-bug report, so it does not sit behind six
slices.
Live gates batched into four sessions; #330 needs none and is the
parallel track for whenever a gate is blocking.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Spotted in the #337 [support] capture and deliberately left out of that
fix so the fix stayed falsifiable. Filed with what is NOT established
attached: whether retail reads the authored Setup field at all is the
first question, and the entry says to grep named-retail before touching
anything. The #337 lineage already burned two diagnoses reasoned from
source.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Transition.FindObjCollisionsInCell discarded a shadow candidate when
|currPos - obj.Position| > sphereRadius + obj.Radius + movement.Length() + 2f
obj.Position is the part ORIGIN; obj.Radius is the physics-BSP ROOT
BOUNDING SPHERE's radius, measured about a centre AP-156 established is
frequently metres from that origin (376 of 973 installed physics-BSP
parts sit further from their part origin than half their own radius,
worst 20.762 m). Geometry deep inside the real bounding sphere was
therefore thrown away before BSPQuery ever ran: solid near the origin,
permeable in a bounded shell beyond it. For the Neftet rock 0xC8766009 /
gfx=0x01004751 the two points are 23.556 m apart, which is #337 — wedged
on the plateau, jumps sinking into the mesh, corpses falling through. A
live capture recorded 7,225 rejections on that one owner, every single
one with wouldAcceptAtCenter=True.
Deleted rather than re-centred. Retail has no distance pre-filter,
disassembled from the PDB-paired v11.4186 binary (CodeView GUID
9e847e2f-777c-4bd9-886c-22256bb87f32) rather than read from Binary Ninja:
CObjCell::find_obj_collisions @0x0052b750 walks shadow_object_list and
calls CPhysicsObj::FindObjCollisions (0x0052b78b) UNCONDITIONALLY; its
only early-out is insert_type == INITIAL_PLACEMENT_INSERT (0x0052b759).
CPhysicsObj::FindObjCollisions @0x0050f050 contains no float compare at
all. CPartArray::FindObjCollisions @0x00518180 is a bare do/while over
parts, and CPhysicsPart::find_obj_collisions @0x0050d8d0 is two null
checks plus a call. Retail's only spatial rejection is the BSP node
bounding-sphere test inside the walk — correctly centred, which is
exactly what the deleted filter was not.
Re-centring it (carry BoundsCenter on ShadowEntry) would have preserved
an invention retail does not have, including a +2f slack and a
movement.Length() term with no retail counterpart, and left a second
reach budget to be tuned forever. Retail's own cross-cell slack constant
is F_EPSILON = 0.0002 m, not 2 m.
The method's comment claimed the filter was "the analog of the part
sorting-sphere early-outs inside retail's CPhysicsObj::FindObjCollisions
— response-neutral, pure perf". Both halves were false and cost #333 and
#337; it is replaced by the disassembly above.
Gate: Issue333BroadphaseReachFilterTests drives the production path
end-to-end (ResolveWithTransition -> FindObjCollisionsInCell ->
CollisionTraversal) on a DAT-free fixture so it runs everywhere, as a
discriminating pair. Sabotage-verified: restore the pre-check and
OffCentreBspFloorStopsAFallingMover reaches z=37.800 — exactly the
unobstructed fall, blockedAtLeastOnce=False — while
CentredBspFloorStopsAFallingMover keeps passing. Without the control a
fixture unable to fall would pass the first test for the wrong reason.
Issue337's skipped TheBroadphaseAdmitsTheSurfaceTheMoverIsStandingOn
asserted the now-deleted predicate and could never have gone green; it
is rewritten as installed-DAT evidence pinning BOTH halves of the
diagnosis and is no longer skipped.
Perf measured, not assumed (Release, synthetic all-BSP cell, per
ResolveWithTransition): at 38 candidates — the live maximum — 10.61 us ->
16.68 us (1.57x); at a deliberately unreachable 200, 17.34 -> 39.48 us
(2.28x); ~0.16 us per additional candidate tested. Over 19,701 live
[reach-q] samples the in-cell count is p50 = 9, p99 = 32, max 38.
The ACDREAM_PROBE_REACH rejectedReach column is kept and is now
structurally 0, so a post-fix capture stays comparable with the pre-fix
one; dropping it would make the two incomparable.
AP-158 retired (110 active AP rows). #333 and #337 closed pending the
user's live acceptance at Neftet.
Solution suite 11,231 passed / 4 skipped / 0 failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Report-only. No production code changed.
The collision mesh is present, correctly shaped, correctly placed in the
world, and the BSP traversal reaches every part of it. The mover never gets
as far as the query. FindObjCollisionsInCell's per-object broadphase measures
the mover's distance to the shadow entry's Position — the part ORIGIN — and
compares it against obj.Radius, which is the physics-BSP ROOT BOUNDING
SPHERE's radius. For 0xC8766009 those two points are 23.556 m apart, so a
mover standing on its plateau is inside the real bounding sphere by ~20 m of
margin and is still rejected. Same defect AP-156 fixed in the flood and #334
fixed in the registration extent walk, left in place at the query site.
Measured, not inferred. An offline replay against the installed DAT
reconstructs all eleven landblock-0x8766 owners and matches the live [geom]
placement exactly (0xC8766002 at (84.699,100.082,13.000) yaw -45.00 vs the
log's objPos + bspCentreOffset). At the position the client fell through, the
production swept query returns a hit on poly 31 at 0.037-0.366 m while the
filter rejects the candidate: distToOrigin=60.434 > maxReach=59.697, distance
to the bounding-sphere CENTRE 37.083 m against a 56.909 m radius. The live
capture recorded that rejection 7,225 times with the probe's own
wouldAcceptAtCenter=True on every one.
Bounded because the dead zone is the shell between maxReach and the true
sphere, up to ~23.5 m thick on the far side. movement.Length() is a budget
term: a 0.25 m walking step gives shortfall +0.60, a 0.72 m step +0.14, and
~0.86 m passes — which is exactly why jumping over the spot works, walking
into it does not, and a corpse falls through.
Three hypotheses refuted by measurement, not by argument:
- "the rock's own mesh never collides" — true of 0xC8766002 and it is
INNOCENT; its geometry is 22.8 m from the wedge and it has zero brute-force
hits over a 12,493-point lattice covering the plateau. It is a candidate
only because it is a 130x147 m owner. The rock actually walked on is
0xC8766009.
- wrong world transform — the offline placement reproduces the runtime
exactly, and a uniform displacement cannot produce a bounded pocket.
- BSP traversal hole — a referee ran the production walk against brute force
at 7,770 on-surface probes across all eleven owners plus 137,423 lattice
points. Mismatch 0 everywhere. A 0.5 m hole map also shows continuous
upward-facing coverage across the whole wedge region.
[geom]'s verdict=coincident was never able to decide this: LogGeometry
compares the physics box against the visual box in the object's OWN LOCAL
FRAME, so it proves shape agreement and says nothing about world placement.
Recorded in the doc so the next reader does not re-trust it.
Retail has no per-object distance filter on the BSP branch. Verified
instruction-by-instruction with cdb against the PDB-paired v11.4186 binary:
CPartArray::FindObjCollisions @0x00518180 is 14 instructions of bare
do/while over parts[i]; CPhysicsPart::find_obj_collisions @0x0050d8d0 is 17
instructions of two null checks plus the call to CGfxObj::find_obj_collisions
@0x00534700. No compare, no float math in either. The in-tree comment calling
the filter a retail analog and response-neutral is wrong on both counts.
The support=object cpNz=1.0000 readings inside the rock are not the rock:
ValidateTransition:6076 is retail's stationary-fall failsafe manufacturing a
flat plane through the sphere bottom, and :5997 is the LastKnownContactPlane
restore holding a stale plane. Both are retail-correct responses to a stuck
body, and they are why the client believes it is standing while ACE rejects
the position.
Preferred fix is to delete the pre-check for BSP entries and correct the
comment; fallback is to measure to the bounding-sphere centre, which also
needs BoundsCenter carried on ShadowEntry. Neither is landed.
The reproducer was confirmed to FAIL when un-skipped, with the numbers above
— this campaign has caught eleven green tests covering nothing, so a fixture
that cannot distinguish the bug is worse than none.
Gates: bin/obj deleted, Release build 0 errors, Core suite 4,287 passed /
2 skipped / 0 failed (baseline 4,286/1 plus three new dumps and the one
deliberately skipped reproducer).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The user is wedged at the top of Neftet rock plateaus, jumps sink into the
mesh, and a corpse falls straight through. ACDREAM_PROBE_REACH already ruled
out its own domain: blocked=0, every candidate tested-ok. Three candidates
remain — terrain support, a collision mesh not where its visual is, or the
transition wedging on an unobstructed path.
ACDREAM_PROBE_RESOLVE alone cannot separate them. It prints a three-value
contact-plane token, no plane normal, no plane height, no terrain sample and
no plane provenance, so all three produce the same line. Two additions:
[support] — one line per resolve for EVERY body, not just the player. A corpse
is a plain physics body with no player-specific logic, so its fall-through is
the cheapest available control on "movement code vs geometry data", and it is
invisible to any player-filtered probe. The line samples the outdoor terrain
INDEPENDENTLY at the body's own out-XY and prints the contact plane's own
height at that same XY. Two heights at one point make support=terrain /
object / none a measurement rather than an inference, and cpSrc= names the
site that asserted the plane so provenance and classification cross-check.
[geom] — once per GfxObj that comes near a mover: the object's physics-BSP
vertex cloud against its visual mesh AABB in the same local frame, through the
same prepared accessors the resolver queries. verdict=coincident REFUTES the
working hypothesis for that object outright; no-physics-bsp / empty-physics-bsp
/ displaced / extent-mismatch each name a specific data defect. Built to
refute, not to confirm — two diagnoses on this defect's lineage have already
been refuted by measurement.
ACDREAM_WIRE_MESH upgrades the existing F2 overlay, which drew a broadphase
proxy cylinder for BSP objects and so could not answer the question at all, to
the real physics-BSP polygon edges (cyan) beside the visual mesh box (magenta)
and the terrain surface (yellow). Own class per code-structure rule 1.
The provenance latch lives on PhysicsDiagnostics, not on CollisionInfo. Two
fields there first — the obvious home — broke the flat/graph differential
referee and the scratch-reset poison test, both of which compare CollisionInfo
member-for-member. Teaching either to skip a member is a one-line green fix
that puts a permanent hole in a referee whose whole job is comparing
everything. Captured as feedback_probe_state_off_compared_types.
Seven tests cover the support classifier's boundaries: a wrong classifier does
not fail to answer, it answers confidently wrong.
Gates: Release build 0 errors; complete suite 11,225 passed / 4 skipped / 0
failed from a cleaned tree — baseline 11,218/4/0 plus exactly the seven new
tests, skips unchanged.
Issue #337 filed with the symptom set, what is ruled out, and a table of what
each possible output means. All of this is TEMPORARY and recorded for
stripping with the physics-probe family.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
acdream had never implemented retail's SECOND cell-membership algorithm.
CPhysicsObj::calc_cross_cells @0x00515230 tests HAS_PHYSICS_BSP_PS at
0x00515285 and jumps (0x0051528f jne 0x515305) to find_bbox_cell_list
@0x00510fc0 for a BSP-bearing object; everything below that jump is the
OTHER algorithm, CObjCell::find_cell_list, and that is all we had. Every
object, BSP-bearing or not, was routed through it.
That path's outdoor expansion is a HARD CAP of one cell in each direction.
CellTransit.AddAllOutsideCells computes minRad = radius, maxRad = 24 - radius
and adds at most the eight neighbours of the sphere's own cell, so for any
radius >= 12 m both boundary tests are unconditionally true and the result is
exactly 3x3. Widening the radius or adding a second sphere is mechanically
incapable of adding a tenth cell. The user's live probe measured the
consequence directly: standing inside a Neftet formation, inCell=2 exempt=2
reached=0 -- the geometry was not a candidate at all.
The port. AddAllOutsideCellsFromParts is CLandCell::add_all_outside_cells
@0x00533360 plus add_cell_block @0x005331d0: base landcell from the FIRST
part's own adjust_to_outside, baseX/baseY within-block, each part's authored
CGfxObj::gfx_bound_box re-fit through all eight corners
(BBox::LocalToGlobal @0x005b2120), floor(v / square_length) where
square_length = 0x7c920c = 24.0f, four accumulators seeded to zero, ONE
rectangle unioned across all parts, FILLED, in GLOBAL lcoords so it crosses
landblocks freely, clamped only to [0, 0x7f8).
BuildShadowCellSetFromParts is find_bbox_cell_list's worklist.
RegisterMultiPart dispatches on the same flag retail does, and
BuildFloodSpheres' BSP arm is deleted rather than left unreachable.
Disassembled from the PDB-paired 2013-09-06 binary, not read from Binary
Ninja: BN mis-renders four separate constructs inside add_all_outside_cells
alone -- a dropped `and eax,0xffff` on baseX, a neg/sbb/and select shown as
identically zero, a wrong get_landcell argument, and both x87 flag tests as
`unimplemented {test ah}`.
ShadowPartGeometry pairs the BSP root sphere with the authored box so no
resolver can answer one and leave the other call site to synthesize a
substitute -- the AP-156 invariant applied a second time, since that split is
what produced AP-156 and then this. The box comes from
FlatGfxObjVisualBounds, already computed by exactly CGfxObj::init_end's
algorithm and already in the prepared package: no bake change, no DAT re-read.
Cost, measured over the installed DATs before any code was written: 1,258
physics-BSP GfxObjs, cells/object p50 4, p90 4, p99 12, max 49. The port is
CHEAPER than the old 3x3 = 9 for 98.97% of them. Row totals (shapes x cells)
over all 1,031 landblocks with BSP owners fall 97,173 -> 15,607 (0.161x);
dense Arwic 0xC6A9 falls 342 -> 43. One landblock more than doubles.
Precondition confirmed before pinning any expected cell set: 0x010046D8's box
is 96 m x 96 m about cell (2,2) = 0x87640013, which independently corroborates
the 3x3-centred-there diagnosis, and its rectangle does contain 0x87640011 and
0x87640019 -- the two cells the probe measured empty.
Register: AP-156's outdoor half CLOSED and its risk column CORRECTED (it read
"extra broadphase candidates, never a missed one", which generalised the indoor
direction to the whole row and is why #334 sat inside it unnoticed). AP-159 +
issue #335 file the unported indoor arm; AD-49 records the seed-time rectangle.
Issue #336 files a fourth load-sensitive test flake seen once during the gate.
Ten tests, every one sabotage-verified in both directions across eight
mutations (dispatch, 8-corner refit, floor-vs-truncation, union-vs-per-part,
map clamp, adjust guard, landblock clamp, box-path-for-everything). The
strongest is an installed-DAT replay of the user's own probe evidence.
Suite 11,208 -> 11,218 passed / 4 skipped / 0 failed; the +10 is exactly the
new tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The reach-filter theory I proposed is REFUTED by measurement, and the real
cause is found. Probe evidence committed as 334-neftet-probe.log (8,401 lines,
ACDREAM_PROBE_REACH at b61f5fd4).
Standing INSIDE the formation, the collision system reports inCell=2 exempt=2
reached=0 rejectedReach=0 — two candidates, both the player's own body spheres.
Nothing was rejected because nothing was there. That kills AP-158 as the cause:
the formation is not in the candidate set at all.
The blocking part the user found is what makes it diagnostic. One object does
collide — gfx=0x010046D8, a BSP with objR=69.471, the landblock's baked rock
geometry — and it appears in cells 0x8764000A and 0x87640012 while being absent
from 0x87640011, 0x87640019 and 0x87630018. Same object, same landblock,
present in one cell and missing from the one directly beside it (grid (2,1)
versus (2,0)).
Cause: BuildFloodSpheres derives cell membership from a single bounding sphere
per part. A 69.471 m radius cannot reach every cell of a 192 m landblock, so
cells beyond it get no registration and the player walks through. Retail does
not use a sphere here — calc_cross_cells 0x00515230 routes BSP objects to
find_bbox_cell_list 0x00510fc0 -> calc_cross_cells_static 0x00518160, a walk
over the object's extent.
Recorded explicitly because the null result was misleadable: AP-156 (b52967de)
did NOT fix this and was never going to. AP-156 corrected the sphere's
POSITION; this is about its COVERAGE. Sequential halves of one weakness, not
competing explanations — and without that note the next reader would reasonably
conclude AP-156 had failed.
Process note worth keeping: I proposed the reach filter, and a DAT sweep would
have "confirmed" it by finding exactly the oversized objects I predicted. The
user insisted on measuring in game instead, which produced the opposite answer
in one run.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
#334 is "large static formations can be walked through on flat ground, and
jumping over them drops you inside" (Neftet, user-reported, pre-existing —
reproduced on 52175aa1, before AP-22/AP-152/AP-156/AD-10). The report as
originally filed pointed at one mechanism. The user's clarification that the
formations are permeable on the ground generally, not only at a boundary
between two of them, widens it to three, and a DAT sweep can only say which
objects COULD fail, never which one IS failing at the spot. So: measure in
game, at the failing spot, and let the log choose.
The three outcomes this probe must tell apart, at the player's own collision
query:
(a) the object IS a candidate in the cell and the broadphase reach filter
rejects it before its BSP is consulted — AP-158 / #333. That filter
measures |currPos - obj.Position|, the part ORIGIN, against the BSP
root sphere's RADIUS plus an acdream-invented 2 m slack. The sphere's
CENTRE is frequently not the part origin (376 of 973 installed
physics-BSP parts sit further from it than half their own radius;
worst 20.762 m), so geometry well inside the sphere can be rejected.
(b) the object is not in the cell's candidate set at all — a membership or
registration failure. AP-156's territory, which did not fix this.
(c) the object is a candidate, is not rejected, and still contributes
nothing because no usable physics BSP resolves for it.
Two line types from Transition.FindObjCollisionsInCell:
[reach-obj] one per candidate, carrying mover guid, target entity id,
GfxObj id, cell, and its disposition — exempt-self, exempt-missile,
rejected-reach, exempt-rule, exempt-ethereal-stepdown, no-shape,
bsp-only-skip, tested-{ok,collided,adjusted,slid}. For BSP candidates
it also carries the origin-measured distance the filter used, the
centre-measured distance it should have used, the budget, the
shortfall, and wouldAcceptAtCenter — the boolean that separates a
false rejection from an honest one. Identity is on every line
(feedback_probe_identity_attribution).
[reach-q] one per cell query, with the per-disposition tallies AND the
raw entry count, emitted EVEN WHEN THE CELL YIELDS ZERO. That last
part is the point: without it, an absence of rejection lines could
not distinguish "nothing was rejected" from "nothing was there", and
a criterion that cannot fail in the presence of the bug it exists to
catch is the trap this campaign has already been caught by once.
`blocked` is the control — it proves the probe can see a working
collision as well as a missing one.
The BSP root sphere is resolved through the SAME production accessor
registration uses (GetFlatGfxObj(id).PhysicsBsp root node, per
LiveEntityCollisionBuilder and ShadowShapeBuilder), so the probe cannot
report geometry differing from what the registry actually emitted — AP-156's
lesson was exactly that: one resolver.
Volume: [reach-obj] de-duplicates per (mover, target, cell) and re-emits at
once whenever the disposition changes or the shortfall crosses a 0.5 m
bucket, otherwise at most once a second; [reach-q] de-duplicates per (mover,
cell) on the whole tally tuple, so any change in what the cell yielded emits
immediately, otherwise at most twice a second. Both emit eagerly on change —
which is exactly when the player walks into the formation — and go quiet
when nothing is happening. Nothing is aggregated away.
Filtered to the player mover, matching PhysicsResolveCapture, so NPC and
remote dead-reckoning resolves do not pollute the capture. The helper is
static and takes everything by parameter so no closure display class enters
the resolve path: Slice I1's 0 B/resolve budget holds with the probe
compiled in and switched off.
TEMPORARY. Strip with the rest of the physics-probe family once #334 is
scored; both the flag and the call site say so.
Clean bin/obj, Release build, full suite 11,208 passed / 4 skipped / 0
failed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
User-reported in live play at Neftet: solid on approach, permeable at a
boundary between two formations, and no floor above — jumping over lands you
through.
NOT a regression from today's collision work, and that was established by
measurement rather than argument: a client was built at 52175aa1 — before
AP-22, AP-152, AP-156, AD-10 and #276's remainder — and the user reproduced
both this and the cliff-edge symptom identically on it. Pre-existing, simply
never filed.
The leading candidate is already filed as AP-158 / #333: the broadphase
measures reach from the part ORIGIN against a `+ 2 m` budget, where retail has
no distance pre-filter at all (CObjCell::find_obj_collisions 0x0052b750
dispatches unconditionally, its only early-out being INITIAL_PLACEMENT_INSERT).
118 of 477 unique BSP GfxObjs exceed that budget and 46 exceed 5 m — and a
"large stone formation" is exactly the class whose geometry sits many metres
from its origin. Solid in the middle, permeable at the edges, no floor
overhead, follows directly.
That also explains why AP-156 did not fix it, which is worth recording because
the null result looked like a failed fix: AP-156 put the object in the right
CELLS; AP-158 is why it is still rejected WITHIN them. Sequential, not
alternative — this issue is the observable proving the second half still bites.
The issue names the one measurement that settles it (the offending Setup's BSP
root-sphere origin offset) and states plainly that no fix should be attempted
first — the `+ 2f` slack is invented, so widening it would be tuning a
non-retail constant rather than removing it.
The user's other report — running off cliff edges instead of stopping — is
already covered by #32 (HIGH, open since 2026-04-29), whose row states
"Local-player edge-slide is unchanged by this work" after its remote half
closed. No new issue filed for it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Both review lenses PASSED; this is the cleanup, not a rescue. Evidence:
docs/research/2026-08-06-ap156-review-closure.md (the review itself is
committed alongside it as the received artifact).
R1 — the load-bearing containment test could not fail. Its truth and flood
values were two hand-copies of the same expression over the same part set,
so the shortfall was algebraically identically zero for any DAT input. The
oracle is now PHYSICS-POLYGON VERTICES — a different DAT field from the
bounding sphere the builder emits, so the two sides can genuinely disagree.
Sabotage-verified three ways after full cleans: dropping the bounds centre
in production reddens it (428 Setups, worst 35.869 m on 0x0200129A, matching
an independent out-of-repo sweep exactly); dropping only the scale on the
centre reddens it (326); and corrupting the TEST's own bounds oracle reddens
it (467) where under the shipped oracle that same corruption was invisible
by algebra. Renamed accordingly. A6's stale "cap control" comment corrected:
that loop is the test's own uncapped re-implementation and cannot observe a
cap regression — the cap is covered in Core.
R2 — the population was understated. 172 is AP-152's DISPATCH population;
AP-156's is 530 BSP-bearing Setups, of which 525 have a flood sphere move
and 428 fail vertex containment before the fix (412 at a 1 cm tolerance —
the review's figure; the gap is 16 Setups between 1.4 mm and 10 mm, real
geometry). 0 fail after, at any tolerance down to zero. Corrected in the
AP-156 row, the section-3 header, the C5c handoff and two test docstrings.
Dated review artifacts are left as written — "170 of 172" was correct for
what they measured, and rewriting evidence to match a later measurement
loses provenance.
A1 — BoundsCenter = default reopened at the type what the commit closed at
the seam. Dropping the default alone would NOT have closed the review's own
scenario (a copied Cylinder call site would write Vector3.Zero explicitly
and stay green), so ShadowShape's constructor is now private and BSP shapes
are built only through ShadowShape.Bsp(..., FlatCollisionSphere localBounds),
which takes radius and centre as ONE value and scales them together. There
is no expression a caller can write that carries one and drops the other.
22 construction sites converted; the same sabotage now reddens 5 Core tests
where the review's sabotage A reached 4, because both BSP producers share
one scaling path.
A2 — #333 is real and bigger than filed, and its retail question is
answered. I disassembled CObjCell::find_obj_collisions @0x0052b750 from the
PDB-paired binary myself (check_exe_pdb.py MATCH) rather than inheriting the
claim: its only early-out is sphere_path.insert_type == INITIAL_PLACEMENT_
INSERT, then it calls FindObjCollisions on every unparented non-self shadow
object UNCONDITIONALLY. Retail has NO distance pre-filter, so acdream's
"+ movement + 2f" reach filter is an invention with no register row — filed
as AP-158, carrying the disassembly, the F_EPSILON = 0.0002 m contrast, and
the measured blast radius (118 of 477 unique installed physics-BSP GfxObjs
exceed its ~2.5 m budget, 46 exceed 5 m). Active AP rows 109 -> 110.
Recorded prominently in three places a reader will hit: TALL PROPS MAY SHOW
NO VISIBLE CHANGE UNTIL #333 LANDS, and a null result at the connected gate
is EXPECTED, not evidence against AP-156.
LOW items. R3: the comment claiming the cited evidence justified the whole
cap line is corrected, but int.MaxValue on the sorting-sphere branch stays —
capping at 1 would take Spheres[0], and retail's one sphere is
CSetup::sorting_sphere, a different DAT field; capping keeps the wrong field
AND flips the substitution under-inclusive (#98/#168 direction). AP-157
already owns it. R4: acdream scales the flood sphere where retail's
find_transit_cells never reads gfxobj_scale — added as a second residual on
AP-156. R5: retail's slack constant carried into AP-158 and #333. A3: the
per-call delegate allocation is back to a cached field, still derived from
the single bounds resolver. A5: noted; b52967de's message cannot be amended.
Gates: all 44 bin/obj deleted before every verdict-deciding build, each test
run gated on a verified "Build succeeded" in the same invocation. Release
build 0 errors / 21 pre-existing warnings. Complete suite 11,208 passed /
4 skipped / 0 failed — reconciles exactly with the e2b2d04c baseline; one
test renamed, none added, removed or skipped. Nothing conflated with the
known load-sensitive flakes #302 / #308 / #321.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Found while fixing AP-156 and deliberately NOT bundled into it: the shadow
broadphase at TransitionTypes.cs:3756-3764 measures `currPos - obj.Position`
against `sphereRadius + obj.Radius + movement + 2f`, where `obj.Position` is
the PART ORIGIN but `obj.Radius` is the BSP root bounding sphere's radius,
measured about that sphere's own centre. The same discarded origin, one layer
down.
It admits a real contact only when the sphere's centre is within about
`movement + 2` metres of the part origin. For Setup 0x02000255 that offset is
9.911 m against a budget near 2.5 m, so a mover touching the upper half of the
prop is discarded before BSPQuery runs.
This is newly load-bearing: before AP-156 those objects were mostly not in the
cell at all, so the filter never got to reject them. AP-156 puts them in the
right cells and this becomes the next gate. It is the first place to look if
the connected session finds a tall prop that still does not block.
Filed rather than fixed because it is a different code path with an unanswered
retail question — the `+ 2f` slack and the movement term look like acdream's
own broadphase rather than a port of anything in CPhysicsObj::FindObjCollisions
@0x0050f050, in which case it needs a divergence row of its own before it is
touched. Bundling it would also make AP-156's connected gate un-attributable,
which is exactly the fault that split AP-155.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The AP-152 retail review (docs/research/2026-08-06-ap152-review-retail.md)
FAILED `4abd1b5e` and is right. `ShadowObjectRegistry.BuildFloodSpheres` took
each physics-BSP part's ROOT BOUNDING SPHERE RADIUS
(FlatCollisionAssetBuilder.cs:393 -> LiveEntityCollisionBuilder.cs:137) and
centred it on the PART ORIGIN (ShadowShapeBuilder.cs:194), discarding the root
sphere's own Origin.
Re-measured independently against the installed client_portal.dat, reproducing
the reviewer's numbers exactly: 376 of 973 physics-BSP parts have
|origin| > radius/2, worst 20.762 m on a 27.708 m sphere (gfx 0x010036DD,
Setup 0x0200129A). Over the 172 Setups AP-152 moved onto that path the emitted
flood FAILED TO CONTAIN the object's own BSP sphere for 170 of them (73
CylSphere-bearing, 97 Sphere-bearing), worst shortfall 9.911 m on Setup
0x02000255 — whose one part's sphere sits 9.911 m above the part origin — and
for 43 the post-AP-152 flood was strictly SMALLER than the pre-AP-152 one.
Indoor flooding is 3-D (CellTransit.cs:601 routes every id & 0xFFFF >= 0x0100
candidate through FindTransitCellsSphere), so a tall prop or door slab was
absent from EnvCells it physically occupies and therefore never a broadphase
candidate there (TransitionTypes.cs:3763 iterates only entries already in the
cell). That is the #98 / #168 class AP-152 exists to remove.
Retail, re-disassembled from the PDB-paired binary (check_exe_pdb.py MATCH,
CodeView GUID 9e847e2f-777c-4bd9-886c-22256bb87f32), every address resolved
back through named-retail/symbols.json:
CGfxObj::physics_sphere is [gfxobj+0x74] (physics_bsp is [+0x78], as
CPartArray::CacheHasPhysicsBSP @0x00518110 reads at 0x00518127), and
acclient pseudo-C 0x00534b5b assigns it BSPTREE::GetSphere(physics_bsp).
BSPTREE::GetSphere @0x005397e0
8b01 mov eax,[ecx] ; BSPTREE::root_node
83c004 add eax,4 ; past BSPNODE::vfptr -> CSphere sphere
So retail's per-part flood sphere IS the BSP root bounding sphere,
ORIGIN INCLUDED (acclient.h: BSPNODE { vfptr; CSphere sphere; ... },
CSphere { Vector3 center; float radius; } -> radius at +0xc).
CPhysicsObj::find_bbox_cell_list @0x00510fc0 adds the object's own cell and
then walks the PART ARRAY: 0x00511012 call 0x518160
(CPartArray::calc_cross_cells_static), which dispatches [edx+0x7c] with
(num_parts, parts, cellarray). Its EnvCell body,
CEnvCell::find_transit_cells @0x0052cae0:
0x0052cb31 mov edx,[eax+0x20] ; CPhysicsPart::gfxobj (CGfxObj**)
0x0052cb36 mov esi,[ecx+0x74] ; physics_sphere (else +0x90 drawing)
0x0052cb4c add eax,0x30 ; CPhysicsPart::pos
0x0052cb5a call Position::localtolocal ; transform the sphere CENTRE
0x0052cb65 fadd [esi+0xc] ; only NOW the radius
Retail transforms the centre through the part's own Position before it ever
touches the radius. Carrying the radius alone is not an approximation of
that; it is a different sphere.
Changes:
* `ShadowShape` gains `BoundsCenter` — the bounding sphere's centre in the
shape's own local frame, scaled like LocalPosition and Radius. Zero for
Cylinder/Sphere shapes, whose LocalPosition already IS their centre.
* `ShadowShapeBuilder.FromSetup` gains a `physicsBspBounds` resolver that
supplies radius AND centre from ONE call, replacing the placeholder radius
plus a downstream substitution. `LiveEntityCollisionBuilder` now holds a
single `Func<uint, FlatCollisionSphere?>` and derives its dispatch predicate
from it, so the gate and the geometry cannot disagree and the radius cannot
be taken while the origin is dropped. That split is what produced this bug;
it no longer exists.
* `FromLandblockBspParts` carries the centre too. A landblock-baked part array
is the same CPartArray walk, so stair runs, fences and rock clusters had the
identical defect. Both storage forms (flat BSP and the graph fallback) are
covered.
* `BuildFloodSpheres` places each sphere at
partWorldPos + rotate(BoundsCenter, partWorldRot), composed exactly as the
ShadowEntry rows are.
* The 10-sphere clamp now applies to the CYLSPHERE branch only. Retail's clamp
is inside CObjCell::find_cell_list @0x0052b9f0
(0x0052ba21 cmp eax,0xa / 0x0052ba28 mov ebp,0xa); the BSP walk has none and
the sorting-sphere overload @0x0052b990 takes one sphere. 7 installed Setups
carry more than 10 physics-BSP parts (max 49, Setup 0x02001A91) and their
tail parts were dropped from the flood entirely. Without this the new
containment assertion would have covered shapes production never floods
from.
Register. AP-155 was two divergences with different code paths, populations
and gates under one id; it is NARROWED to its static-publication half and its
flood half is split out as AP-156 WITH ITS DIRECTION CORRECTED. AP-155(b)
recorded the approximation as over-inclusive — "floods MORE cells rather than
fewer, the safe direction for membership" — and that false direction was the
stated reason the residual was safe to defer. It was under-inclusive for 170
of 172. AP-156 records the correction, this fix, and the one genuine residual:
acdream's sphere-vs-portal traversal where retail walks each part's sphere
against the cell's own portal planes. AP-155(b)'s "acdream approximates
retail's bounding BOX" was wrong too — find_bbox_cell_list forms no box.
AP-157 filed for the review's F4: retail's third branch floods from ONE
CPartArray::GetSortingSphere @0x00518b00 ([partArray+0x54]+0x70 =
CSetup::sorting_sphere; 4,154 of 5,935 installed Setups carry a non-zero one)
where acdream floods from every Sphere shape, and acdream's cylinder flood
ignores CylHeight. Deliberately NOT bundled here: different branch, disjoint
population, different live gate. Active AP rows 107 -> 109, literal count.
Tests. Both flood tests the review named substituted a CONCENTRIC Radius = 14f
at LocalPosition = Zero — the one configuration in which the defect cannot
appear. Every fixture is now off-centre by default, and
`FromSetup_CylSphereAndBspSetup_FloodsTheBspFootprint` drives the production
`physicsBspBounds` seam instead of hand-substituting. Five new facts: the
flood centres on BoundsCenter not the part origin; it rotates BoundsCenter by
the part rotation; it caps cylspheres at ten but never the BSP parts; the
landblock path carries the scaled centre in both storage forms; and an
installed-DAT containment sweep asserting every emitted BSP flood sphere
contains that part's real bounding sphere at entity scale 1.75, behind four
external controls — 973 parts, 376 off-centre, 172 affected, and 170
would-fail-if-the-origin-were-discarded, the last of which fails if the
population ever stops exercising the field.
Nine sabotages, each reverted and re-verified:
A drop BoundsCenter from the flood -> 3 Core
B rotate by entity rot, not part rot -> 1 Core (the rotation fact only)
C FromSetup discards the origin -> 1 Core + 2 App + 1 Content
(the shipped defect, now caught in three projects)
D drop entScale on BoundsCenter -> 2 App + 1 Content
E landblock flat branch drops the centre -> 1 Core
F landblock graph branch drops it -> 1 Core
G drop partScale on the landblock centre -> 1 Core
H re-apply the 10-cap to every branch -> 1 Core
I remove the cylsphere cap -> 1 Core
AP-152's own two sabotages re-run against this tree: the step-0 gate disabled
still reddens exactly its five facts with Headless 89/89 green, and
cylinder-first flooding still reddens exactly one.
Clean Release build after deleting all 44 bin/obj: 0 errors, 21 pre-existing
warnings. Complete suite 11,208 passed / 4 skipped / 0 failed, +5 on the
11,203 baseline at 4abd1b5e — Core 4264 -> 4268, Content 126 -> 127, App
unchanged (one rename, not an addition). No new skips.
NOT yet gated live. This moves shadow-cell membership for real objects, in
both directions, and the connected session must look for both: props and doors
that START blocking from a neighbouring cell (the 73 CylSphere+BSP Setups),
AND ones that STOP blocking (the 99 Sphere+BSP Setups can shrink; 43 shrink
below their pre-4abd1b5e size, which is the regression this fixes). Tall
indoor props and door slabs — the ones whose sphere sits metres above the part
origin — are where the change is largest.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The register row predicted "catching or stopping on a doorway sill". That
symptom could not have been occurring. `Transition.BspOnlyDispatch`
(TransitionTypes.cs:1348, landed 2026-05-25 as A6.P7) already skipped both
primitive branches (:3911, :3954) whenever the target's wire PhysicsState
carries HAS_PHYSICS_BSP_PS, and ACE sets that bit from CSetup.HasPhysicsBSP
for every affected Setup. The extra primitive was never tested for collision.
The live defect was CELL MEMBERSHIP. The same shape list feeds
`ShadowObjectRegistry.BuildFloodSpheres`, which had no such guard and
preferred Cylinders over everything whenever any Cylinder existed — retail's
SECOND priority applied ahead of its first. For the 73 CylSphere+BSP Setups
acdream therefore flooded shadow cells from the cylinder and never from the
slab: an object absent from cells it physically occupies, which is the
#98 / #168 symptom class, not the door-collision class the row named.
Retail, re-disassembled from the PDB-paired binary (v11.4186, CodeView GUID
9e847e2f-777c-4bd9-886c-22256bb87f32, check_exe_pdb.py MATCH) rather than
taken from Binary Ninja, which drops flag tests:
CPhysicsObj::FindObjCollisions @0x0050f050
0x0050f165 test dword [esi+0xa8], 0x10000
0x0050f16f je 0x50f1a2 ; clear -> primitive dispatch
0x0050f18d call 0x518180 ; CPartArray::FindObjCollisions
0x0050f19d jmp 0x50f2b0 ; UNCONDITIONAL, past BOTH primitive loops
; (CylSphere 0x50f1a2, Sphere 0x50f21d)
0x0050f1d6 jae 0x50f317 ; CylSphere loop exhausted -> RETURN
0x0050f22f je 0x50f31b ; zero Spheres -> RETURN seeded OK_TS
CPhysicsObj::calc_cross_cells @0x00515230
0x00515285 test dword [esi+0xa8], 0x10000
0x0051528f jne 0x515305 -> CPhysicsObj::find_bbox_cell_list @0x00510fc0
0x005152d1 call 0x52b9f0 ; cylsphere branch, below the jump
0x005152fb call 0x52b990 ; sorting-sphere branch, below the jump
Priority at both consumers: BSP -> CylSphere -> Sphere -> nothing. BSP wins.
Every address above was resolved back to its symbol by exact lookup in
named-retail/symbols.json.
Changes:
* `ShadowShapeBuilder.FromSetup` gains a step-0 dispatch gate. Steps 1 and 2
are skipped entirely when any part's EFFECTIVE GfxObj carries a physics
BSP. The gate and step 3 now share one `EffectivePartGfxObjId` helper, so
they cannot read different identities — a gate on `setup.Parts` would,
after an ObjDesc swap, suppress the primitives while step 3 emitted
nothing and `Build` returned null, deleting the entity's collision.
Emission order is unchanged. This also removes acdream's undeclared
reliance on the server sending the flag: the gate is derived from the
parts, exactly as CPartArray::CacheHasPhysicsBSP @0x00518110 derives it.
* `ShadowObjectRegistry.BuildFloodSpheres` now applies calc_cross_cells'
own order: BSP, else Cylinder, else everything. Given the gate above this
is a no-op for every shape list acdream produces (FromSetup is now
exclusive; both landblock-static publishers already emit homogeneous
lists), so the measured membership delta remains attributable to the
gate alone. It is kept for the same reason BspOnlyDispatch is kept: retail
genuinely dispatches here, and it guards a future additive producer.
`Transition.BspOnlyDispatch` is deliberately untouched.
Register: AP-152 RETIRED with its four false statements corrected — the risk
statement (the symptom was already inert); "small and centred at the part
origin" (max primitive is 6.714 m, and 0x0200086E's sphere origin is
(0.759, 0.165, 5.842)); the cottage door's "~14 cm base Sphere" (it is
0.100 m; 0.141 is Setup.Radius, which AP-22 proved is never collision
geometry); and naming one pinning test where two existed. AP-153/154/155
filed: retail's dispatch flag is cached once at InitPartArrayObject+0x7e
where acdream's gate is live; the query-time guard takes a client-derived
flag off the wire; and the static publishers emit Setup Spheres as
height-capped Cylinders while BuildFloodSpheres approximates retail's
bounding box with bounding spheres.
Tests. Both pinning tests corrected, neither deleted:
`FromSetup_DoorSetup_ProducesFourShapes` -> `..._EmitsBspPartsOnly`;
`FromSetup_DoorSetup_SphereAtExpectedLocalOffset` re-hosted on
`_ => false`, the DAT-real configuration for the 3,605 Sphere-only Setups.
`FromSetup_ScaleFactor_MultipliesAllRadiiAndOffsets` was the campaign's
eighth green test covering nothing — its assertions sat inside
`if (CollisionType == Cylinder)` on a fixture with zero CylSpheres, so only
`Scale == 2.0f` ever ran. Proved empirically: with the sphere radius scale
deleted, the old body passes and the corrected body fails. Three new facts:
the effective-identity gate, the App-layer CylSphere+BSP registration (no
App fixture combined the two before), and the flood-set dispatch. One new
installed-DAT sweep pins 172 affected Setups (73 CylSphere+BSP, 99
Sphere+BSP) behind external bucket controls, re-measured independently and
agreeing exactly with the filing commit's separate sweep.
All eight sabotages run and reported; every discriminating fact reddens in
the intended direction and only there. Clean Release build after deleting
every bin/obj: 0 errors. Complete suite 11,203 passed / 4 skipped / 0
failed, +5 on the 11,198 baseline at ec29a732 — exactly the five added
facts, no new skips.
Blast radius, corrected: the FromSetup half is graphical-only (its sole
production caller is LiveEntityCollisionBuilder in AcDream.App, which
AcDream.Headless cannot reference — Headless -> Runtime -> Core/Content).
The BuildFloodSpheres half lives in AcDream.Core and DOES execute in
Headless via LandblockPhysicsContentBuilder, but is behaviour-neutral there
because both of that builder's registrations pass homogeneous lists.
Headless suite green at 89/89.
NOT yet gated live: this changes shadow-cell membership for 22 Setups used
by 151 Door weenies and 38 stationary props. Needs a connected session.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
#331 reported that `PhysicsEngine.ResolveWithTransition` refuses ALL uphill
motion whenever a `body:` is supplied. It does not. It refuses a step whose
sub-step offset is exactly anti-parallel to a live sliding normal — the
#137-family absorb this project already recorded as retail-faithful.
Measured on the same fixture, same gradient, same body, varying only the
heading relative to the slope gradient:
(0, -0.1, 0) cross-slope 0 -> zero movement, latched
(0.0001,-0.1, 0) cross-slope 0.0001 m -> zero movement, latched
(0.001, -0.1, 0) cross-slope 0.001 m -> climbs 0.176 m in 5 ticks
(0.01, -0.1, 0) cross-slope 0.01 m -> climbs 0.176 m in 5 ticks
The threshold is retail's own F_EPSILON small-offset abort (0.0002 m): about
0.11 degrees off the exact gradient at a 0.1 m step. `RemoteRampHarness`
builds a ramp whose gradient is exactly along Y and the original probe pushed
exactly along -Y, so it hit the measure-zero case with probability 1.
The latch itself is production-real in mechanism — a pure gravity fall under
the production RuntimeRemotePhysicsUpdater, with no fixture settle seam
involved, lands leaving Contact|OnWalkable|Sliding with slidingNormal (0,1,0)
— but every link is faithful to retail, verified in the PDB-paired binary
rather than Binary Ninja (BN typed find_transitional_position `void` and
dropped the load-bearing return value):
validate_walkable sets collision_normal from the terrain plane when
OBJECTINFO CONTACT is clear 0x0050d251 / 0x0050d261 / 0x0050d26c
validate_transition converts it unconditionally 0x0050ac19-0x0050ac30
set_sliding_normal zeroes Z AND re-normalizes 0x0050a060
SetPositionInternal persists SLIDING_TS 0x005154c2 / 0x005154e1
get_object_info re-seeds it next frame 0x00511d44 / 0x00511d4f
find_transitional_position returns
`i != 0 && state == OK` on the step-0 abort 0x0050c0ed -> 0x0050c089
ACE agrees (Transition.cs:1027, CollisionInfo.cs:58). No production code
changed; no divergence introduced, so no register row.
What lands is the coverage whose absence made this invisible — nothing in the
suite asserted that a body-bearing mover makes uphill progress on a walkable
slope, and the test that found #331 passed vacuously because the body never
moved:
RuntimeRemoteUphillProgressTests.ARemoteWithABodyClimbsAWalkableSlopeAndKeepsItsFeetOnIt
per-tick climb + surface tracking under a realistic off-gradient heading.
SAB-A1 AdjustOffset -> Vector3.Zero reddens at tick 1
SAB-A2 fixture gradient -> 0 (flat) reddens at tick 1
RuntimeRemoteUphillProgressTests.AnExactlyUpSlopeOffsetIsAbsorbedByThePersistedSlidingNormal
characterization pin for the absorb, with the retail anchors inline.
SAB-B1 delete the get_object_info sliding seed reddens (climbs to 57.7544)
SAB-A1 reddens
SAB-A2 reddens
NON-discriminating, measured and documented: making the final tick
exactly up-slope leaves it green — by then the latch is already cleared.
RemoteRampHarness gains a warning block naming the axis-alignment trap so the
next vacuous uphill assertion is caught at authoring time.
Suite re-measured from a full clean (43 bin/obj removed): 11,198 passed /
4 skipped / 0 failed, against the 11,196/4/0 baseline at 0d62a5ff — exactly
the two tests added.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Campaign closed by user direction after the #280 connected gate passed.
GATE RESULT. #280 user-accepted: "now portal space takes longer but terrain is
complete when I exit" — both halves of the specified criteria, a measurably
longer hold and a complete destination on reveal. Probe evidence: three Portal
reveals plus a Login reveal, every one at radius=12 where pre-fix it was a
hardcoded 1, each portal hold raising the wait cue at ~5.0 s before completing.
An accidental but genuine A/B came out of the same session. An earlier run set
ACDREAM_PROBE_REVEAL_RADIUS=1 — that variable is a radius VALUE, not an on/off
flag — which forced the pre-fix window. The user saw the original defect under
it and not under radius=12. That is the before/after pair the gate asked for,
obtained by mistake. Recorded prominently because the same mistake would
silently reproduce the bug for the next person.
WHAT IS NOT CLAIMED. The ledger closes with most connected gates outstanding
BY USER DIRECTION, not because they were discharged: D-1's two reachability
scenarios, AP-136's six-step park protocol, route-7 thickening (the
remote-teleport probe recorded ZERO lines), the two-client observation, the
nine-stop soak, and the lifecycle/reconnect route. The closeout's section 2.6
is a table of exactly this, and both the campaign plan banner and this commit
say that anyone citing "the campaign passed" must cite it alongside.
THE PROBE FAMILY IS DELIBERATELY NOT STRIPPED. Closing the campaign would
normally retire the six ACDREAM_PROBE_* flags, but their gates were never run,
and stripping now would delete precisely the instrumentation those owed gates
need — the failure the handoff's own rule exists to prevent. Honouring that
rule means not stripping even though the campaign is closing.
ACDREAM_PROBE_REVEAL_RADIUS is also kept despite #280 closing, because AP-149
and #326 are open and would both want the same A/B harness.
#280 is marked CLOSED in ISSUES with its gate evidence, and its residual
AP-149 is restated there: our outer ring accepts terrain-only readiness where
retail's PreFetchCells also requires each landblock's LandBlockInfo and every
building's EnvCells, so distant SCENERY may still fill in after reveal even
though terrain does not. Not folded in — it costs further hold time and is a
game-feel call.
Memory updated with the campaign's closed state and the follow-up order:
#331 first, then AP-152, #330, AD-65.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Closes the automated half of C5c. Everything still owed needs the user at a
live client, and the probe strip cannot precede it.
AUTOMATED GATE — PASS. Complete Release suite on the final binary at
7b3e2895, run after deleting all 44 bin/obj directories rather than trusting
a rebuild flag: 11,196 passed / 4 skipped / 0 failed across all nine
projects. The clean-build precaution is not ceremony — this session had three
incidents of a runner serving a DLL that still contained deleted code, one of
them under -t:Rebuild. Campaign net: 11,106 at 02578441 -> 11,196, +90, with
no new skip anywhere and none of #302/#308/#321 firing.
STATE. Every implementation item in the placement cutover is landed and
dual-reviewed: C5b, #280, #276's remainder, AP-22 and AD-10, all with both
lenses PASS. #309 was accepted as a standing divergence by user decision
rather than fixed. What remains is connected/visual work plus the ledger
close.
WHAT THE HANDOFF CARRIES that a reader would otherwise have to rediscover:
- The connected gates owed, with the detail that matters — #280's route needs
a LIFESTONE leg because the original repro was a recall, not a /teleloc;
D-1's two reachability scenarios have never been reproduced live; and
AP-136's six-step park check SURVIVES #309's deferral because it validates
the shipped rollback path, not the deferred fix. Strip the probes after
those, never before.
- Twelve issues filed (#321-#332). #331 is flagged first: its discriminator
turned out to be the `body:` parameter rather than the fixture, it
reproduces under the local player's own call profile on ramps as shallow as
1.1 degrees, and nothing in the suite asserts uphill progress on a walkable
slope — the test that found it passed vacuously.
- Register movement, including AD-65's corrected magnitude (25%/50%, not
13%/29% — the row stated cos^2 and quantified 1-cos) and why that matters:
it is a live lead for #269, and #269's existing do-not-retry covers friction
and jump chains, not AdjustOffset.
- The bisect hazard from the C4 handoff, carried forward verbatim.
- Nine process findings stated as rules, each paid for this session. The two
that cost the most: a blast-radius survey only reaches as far as the call
graph its author walked (C5b missed an entire host with 11,000 tests
green), and a test is not evidence until sabotage proves it discriminates
(seven green-but-empty tests found or avoided).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Both AD-10 review lenses PASS; the deletion stands. These are the findings
they raised. One production file touched, comment-only.
AD-65 WAS UNDERSTATED BY HALF, and it is the finding that matters. The row
states the factor as cos^2(theta) and then quantified 1-cos(theta): "13% at
30 degrees, 29% at 45". The correct figures are 25% and 50%. This is not
algebra alone — #331's probe in the same push measures 0.0735 m travelled for
a 0.1 m request at 30.96 degrees, i.e. 26.5% short, which is exactly
cos^2(30.96). AD-65 is a LEAD for #269's slope-slide residual; at the
understated magnitude it reads as marginal and could have been dismissed. At
50% short at 45 degrees it is a serious candidate. I repeated the wrong figure
in conversation before the review caught it.
"VERBATIM/FAITHFUL PORT" of Transition.AdjustOffset was asserted in five
places and was false as of the very next commit, which filed AD-65 and AD-66
against that same function. Corrected to "structurally exact, with exactly two
filed divergences" in the register row and the production doc comment.
RECORDED, and it favours the change: the redundancy measurement is CONTINGENT
on AD-65 — the two mechanisms agree today partly because both under-travel
downhill. That makes this deletion a PREREQUISITE for fixing AD-65 rather than
merely compatible with it; had the projection survived, correcting
AdjustOffset would have re-introduced a disagreement between two live
projections. The record claimed no such thing and should have.
UNTESTED AXIS recorded: the contract's T2 — its mandatory wrong-plane-versus-
right-plane discriminator — was dropped without record, breaching the
contract's own clause requiring exactly that to be written down. The
consequence is precise: the deletion is measured, but the change's only
claimed BENEFIT (a walkable non-terrain surface now gets the committed contact
plane instead of terrain far below) has zero automated coverage and rests on
source reasoning. Stated in the row rather than left implied.
#331 SEVERITY RAISED from UNKNOWN — the discriminator is known and it is not
the fixture. With `body: null` the same uphill sweep climbs (ok=True, moved
(0, -0.0999, +0.060)); with a body supplied it returns ok=False and zero
movement, under a call profile identical to the local player's
(IsPlayer|EdgeSlide + the human two-sphere Setup). A diagonal request keeps
cross-slope X and zeroes only up-slope Y, and it fires on a 1.1 degree ramp.
So "confined to the synthetic fixture" is no longer the comfortable default:
the failing call shape is the shape production uses. Nothing in the suite
asserts uphill progress on a walkable slope, which is why it was invisible —
the test that found it passed vacuously, because the body never moved.
Also: malformed XML doc on ComposeOffset (duplicate </summary> swallowed the
retirement note from tooling) fixed; the placement-cutover plan's item 5 and
its stale "After C5" line now record AP-22 and AD-10 as retired.
Core builds clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two findings from the AD-10 work that are out of its scope and are filed
rather than absorbed. Neither is caused by AD-10 and neither is affected by
its deletion.
#331 — PhysicsEngine.ResolveWithTransition returns ok=False and the
unchanged input position for EVERY uphill step on the synthetic
constant-gradient terrain ramp, while the identical downhill step succeeds
and produces a correct slope-following result. Probed and ruled out:
gradient (fails at 2.9 degrees as at 31), step size, cell boundaries
(five start positions with the cell id recomputed), and Z seating. It is
not an axis bug either — inverting the ramp so it rises along +Y makes +Y
the failing direction, so the failure tracks the slope.
Filed with severity UNKNOWN on purpose. Players demonstrably walk uphill
in acdream and the local player runs the same call, so either production
terrain differs from what the fixture publishes (AddLandblock only, no
flat-collision statics) or something in the live arguments does. That was
not traced, and guessing which would be exactly the kind of inference this
campaign keeps getting burned by. The issue names the one probe that
decides it.
It surfaced because an uphill counterpart to the AD-10 tracking test was
written, PASSED, and was then found vacuous — the body never moved, so it
"stayed on the surface" by standing still. That test was dropped rather
than shipped. Any future uphill assertion against that harness is vacuous
the same way until this is resolved, which is reason enough to record it
even if production is fine.
#332 — Headless bots appear to have no remote dead-reckoning at all.
RuntimeRemotePhysicsUpdater has exactly one production instantiation,
AcDream.App/Physics/RemotePhysicsUpdater.cs:46, and src/AcDream.Headless/
never names it or RemoteMotion. Remote entities on that host would move
only at UpdatePosition cadence. Filed as an observation for the headless
owner to judge, adjacent to #330 but a separate mechanism.
#332 also records the reasoning trap it exposes, because it inverts the
C5b lesson rather than repeating it: RemoteMotionCombiner is in Core and
RuntimeRemotePhysicsUpdater is in Runtime, so "therefore headless runs it"
is the natural correction to C5b's graphical-only survey — and it is
wrong. Assembly placement is not reachability; the instantiation census
is. AD-10 designed no headless gate for exactly this reason, and a passing
one would have been vacuous evidence.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Both were found inside acdream's port of CTransition::adjust_offset while
retiring AD-10, and neither had a register row — grep confirms no existing
row mentions snap_to_plane, SnapToPlane, naturalResting, or away-plane.
Filed as AD-65 and AD-66. Neither is fixed here: both change LOCAL-PLAYER
movement feel and need their own visual gate, and folding them into the
remote-movement change would have put a local-player regression behind the
wrong acceptance test.
AD-65 — the `collisionAngle > 0` arm substitutes `result -= N * angle` for
retail's Plane::snap_to_plane call, making the if and else arms
byte-identical. snap_to_plane (0x00509c50) writes only v.z and leaves XY
alone, so acdream descends slopes 13% slow at 30 degrees and 29% slow at
45. Uphill is correct. Recorded as a LEAD for the open #269 slope-slide
residual, explicitly not a diagnosis — the direction fits but nothing here
establishes causation. #269's friction and jump chains are byte-exonerated
and are not re-audited; adjust_offset is a different function.
AD-66 — the safety push-out substitutes `radius * Normal.Z` for retail's
bare `radius` in both the trigger and the zDist numerator, knowingly and
with a written rationale. The rationale may be right; the missing row is
the defect. The code comment's "ACE and the published pseudocode have the
original threshold" understates the case — the retail binary has it.
Both directions are byte-verified against the PDB-paired v11.4186 binary
(GUID 9e847e2f-777c-4bd9-886c-22256bb87f32, check_exe_pdb.py MATCH), not
inferred from pseudo-C: Binary Ninja renders every x87 comparison in this
function as the fnstsw/test-ah mush and cannot be read for branch
direction. AD-65's row records the exact three instructions and the FPU
condition-code reasoning; AD-66's records the four operand loads showing
neither site multiplies by N.z, plus both float constants read from the
image (0x795344 = 0.0f, 0x7c6878 = 0.00019999999494757503f).
AD-10's own retail anchor was corrected in the previous commit for the
same reason: pc:272296-272346 truncated the sliding-normal validity gate
at the head and the whole safety push-out block at the tail.
No code change; no test change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Stage 0's measurement (previous commit) says the projection is redundant,
so AD-10 retires by deletion rather than by narrowing.
The measurement. With the sample forced to null at BOTH fork sites, from a
clean build:
* a remote running 30 ticks down a 31-degree walkable ramp produces a
BIT-IDENTICAL trajectory, position for position;
* on an 8.4-degree ramp the two differ by at most 2.8e-5 m in Z after 30
ticks (0.03 mm) and are identical in X and Y — float ordering noise
from projecting twice against the same plane rather than once;
* the whole AcDream.Runtime.Tests suite is unchanged.
That is what redundancy looks like, and the arithmetic explains it. The
boundary projection and Transition.AdjustOffset are the same operation
(v -= N * dot(v, N)) against the same plane, and the composition is
idempotent: a vector already on the plane has dot(v, N) == 0, so the
sweep's own projection is a no-op on an already-projected offset and the
full-strength projection on an unprojected one. Either alone produces the
same offset. On terrain a THIRD mechanism, ValidateWalkable's push-out,
re-seats the sphere on the plane every sub-step regardless.
Deleted:
* both RuntimeRemotePhysicsUpdater sample sites (the host and no-host
fork branches carried the block verbatim — the AP-22 shape, a row
naming one site where two exist);
* the terrainNormal parameter and projection block on
RemoteMotionCombiner.ComposeOffset;
* the same block on ComputeOffset, which has no production callers but
held a second copy of the divergence, so leaving it would have made
the row's retirement false;
* PhysicsEngine.SampleTerrainNormal, now callerless.
Removing the parameter rather than passing null is deliberate: it is what
makes a future one-site-only regression a compile error instead of a
silent half-fix.
Two tests went with it —
ComputeOffset_RootMotionFallback_SlopedTerrainNormal_ProjectsZOntoSlope and
its flat-ground twin. Both were weak on their own terms: they drove the
production-dead ComputeOffset and computed their expected values by
re-implementing the projection formula, so they could catch a wrong
MULTIPLY but never a wrong PLANE — which is exactly what the divergence
was. The surviving coverage is geometric and runs the production tick.
Three claims in the old row did not survive contact with the code and are
recorded in the retired row rather than quietly dropped: the justification
(remotes do run the sweep); the description of ComposeOffset's guard as
"interpolation-active" when the code reads `if (!interpolationOverwrote`;
and the roof clause, stale since Bug B gated the sample on OnWalkable —
a steep roof is OnWalkable == false, so the path never ran on #32's
geometry. The retail anchor is corrected too: pc:272296-272346 truncated
both the sliding-normal validity gate at the head and the entire safety
push-out block at the tail. The whole function is 0x0050a370,
pc:272271-272393.
This does not fix#32 and does not partially fix it. #32's remote half was
already closed at 204d0ae0. What deletion does improve is the case #32
never covered: a remote on a WALKABLE non-terrain surface — a bridge, a
dock, a gentle roof, a ramp inside a building — where the terrain sample
returned the plane of the ground far below and applied a wrong plane
rather than none. That surface now gets the body's own committed contact
plane, because that is the only projection left.
The planning contract this work executed is committed alongside as
docs/research/2026-08-06-ad10-contract.md.
Release build 0 errors. Complete solution suite 11,196 passed / 4 skipped
/ 0 failed against the ef976c6d baseline of 11,195 / 4 / 0 — reconciled
exactly as +3 new Runtime tests and -2 deleted Core tests.
Visual gate outstanding: G1 (the ~5 Hz staircase on rolling terrain) is
the veto criterion and runs first; then slope-descent smoothness, a
walkable non-terrain surface, the #32 roof scenario, and flat ground.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
AD-10 claims the remote slope projection is "relocated" out of the sweep
because "remote bodies don't run a full local transition sweep". That
justification is false at HEAD: RuntimeRemotePhysicsUpdater.Tick calls
PhysicsEngine.ResolveWithTransition with the remote's own body, and that
sweep runs acdream's verbatim port of CTransition::adjust_offset
(0x0050a370, pc:272271-272393) once per sub-step. So the boundary
projection is an EXTRA layer, not a relocation — and whether it is doing
anything the sweep does not is a measurement, never an argument.
This commit builds the fixture for that measurement and changes no
production code.
RuntimeRemoteSteepContactSlideTests' private Harness is extracted to
RemoteRampHarness so the new tests share it instead of cloning ~180 lines.
The extraction is behaviour-preserving; its only additions are the
fixture's own TerrainSurface (so an assertion about "is the body on the
surface" is answered by the surface geometry rather than by
re-implementing what the code under test computed), a SurfaceZ helper, and
a Tick overload that supplies a per-frame body-local root displacement —
the locomotion-cycle push a running remote actually carries. All ten Bug B
tests pass unchanged against it.
RuntimeRemoteSlopeProjectionTests then drives the production tick 30 ticks
down a 31-degree walkable ramp and asserts, on EVERY tick rather than at
the end, that the body's root stays within 5 mm of its settled offset from
the terrain beneath it. A staircase catching up on the final tick would
pass a start/end comparison; 30 unprojected ticks accumulate ~1.8 m.
Sabotage results, all from clean builds (bin/obj deleted), reported in
both directions:
* Discard the sweep's answer (Body.Position = postIntegratePos instead
of resolveResult.Position): RED at tick 1, body 0.05999 m off the
surface. This is the tracking test's discriminating sabotage.
* Flatten the ramp to gradient 0: RED on the anti-vacuity guard
(dz = 0.0000 m). That guard exists because the tracking assertion
passes trivially on flat ground, where Z never has to move.
* Short-circuit Transition.AdjustOffset to `return offset;`: GREEN.
Recorded, not hidden — it is the reason the contract's proposed T1
sabotage was rejected. On terrain the sweep has a SECOND independent
way to plant Z: ValidateWalkable's push-out re-seats the sphere at its
natural resting distance from the terrain plane every sub-step.
Removing the step-down probe as well does not change it either
(measured). The tests therefore assert the OUTCOME the projection
exists for, and say in their own doc comments that they are not unit
tests of adjust_offset and must not be cited as such.
One test the contract asked for is deliberately absent. An uphill
counterpart was written, passed, and was then found VACUOUS: on this
fixture ResolveWithTransition returns ok=False for uphill motion and the
body does not move at all, so it "tracked the surface" by standing still.
That finding is filed separately rather than shipped as a green test.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The register half of 619de97a, which was silently dropped — its heredoc
invoked `python`, which does not exist on this box (`py` does), so the edit
never ran while the commit still reported success. Worth noting as its own
small lesson: a shell that fails inside a compound command can leave a commit
claiming work it did not do.
Content of the correction, from the AP-22 architecture review: the retirement
commit's "Headless.Tests 89/89 exercises the site-3 copy" is false, disproved
by sabotage — restoring the invented cylinder in both static sites left the
whole suite green. Two of three deletions, including the headless-only one,
rest on the installed-DAT reachability proof alone. Also recorded: sites 2/3
used the wider `Radius > 0f` guard (differing from site 1's over the DAT by
exactly one Setup, 0x02001657, denormal radius 1.3e-39); the load-bearing fact
is that all 1,652 no-primitive Setups carry Radius exactly 0; and retail's
report_object_collision does read GetHeight for the quadrant field, which is
not a refutation of the FindObjCollisions shape-dispatch claim.
Reachability is now independently reproduced by four decoders plus
tools/SetupInspect.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
From the AP-22 dual review (both lenses PASS). No production change.
THE RECORD WAS WRONG. bc4679cd claimed "Headless.Tests 89/89 exercises the
site-3 copy". The architecture review disproved it by sabotage: restoring the
invented cylinder in BOTH static sites left the entire suite green. No test
anywhere references PublishStaticCollision, and the headless suite's dummy DAT
proxy makes LandblockLoader.Load fail for every landblock, so CreatePublication
returns before reaching it. Two of the three deletions — including the
headless-only one — are pinned by the installed-DAT reachability proof ALONE.
The deletion is still correct; the evidence claim was not, and a successor
trusting it would think those sites had regression cover they do not have.
THE TEST NOW COVERS WHAT IT CLAIMED. Its comment said "the exact guard the
three deleted copies used", but site 1 guarded on `Radius > 0.0001f` while
sites 2 and 3 used the strictly wider `Radius > 0f`. Those are not the same
predicate: the review measured that they differ over the installed DAT by
exactly one Setup, 0x02001657, whose radius is the denormal 1.3e-39. The test
now evaluates BOTH and asserts each is empty, so the wider guard the
headless-reachable deletion actually used is no longer asserted by proxy.
Sabotage-verified: widening the new guard to `>= 0f` reddens it (1,652
zero-radius Setups appear), so the assertion is live rather than vacuously
empty over real DAT data.
AP-22's row also corrected for two precisions the reviews surfaced: the
load-bearing fact is that all 1,652 no-primitive Setups carry Radius exactly 0
(not the 1,294 first cited), and retail's `report_object_collision` DOES read
GetHeight for the quadrant field — recorded so a future reader does not mistake
it for a refutation of "never collision geometry", which is a claim about
FindObjCollisions' shape dispatch only.
Reachability now independently reproduced by four decoders — the contract's
sweep, the implementer's parser, and both reviewers' from-scratch parsers —
plus tools/SetupInspect agreeing bit-for-bit on the cited ids.
Content.Tests 125/125. No new skips; #302/#308/#321 did not fire.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Retail synthesizes NO shape for a shapeless object, so the fix is deletion,
not a corrected height formula.
CPhysicsObj::FindObjCollisions @0x0050f050 dispatches exclusively -- BSP xor
CylSphere xor Sphere xor nothing. The BSP branch leaves via an unconditional
`jmp 0x50f2b0` at 0x0050f19d and cannot reach the primitive branches; a
CylSphere-bearing object that survives its loop returns rather than falling
through to the Sphere loop; and with zero cylspheres, zero spheres and no
physics BSP, `0x0050f22f je 0x50f31b` branches straight to the epilogue,
returning the OK_TS seeded at `0x0050f13b mov edi,1`. CPartArray::GetRadius
(0x005180a0) and GetHeight (0x005180b0) are absent from the function's entire
call set -- Setup.Radius/Height serve attack cones, cylinder_distance and
MoveTo, never collision geometry. Disassembled directly from the PDB-paired
binary (GUID 9e847e2f-777c-4bd9-886c-22256bb87f32) rather than read from the
Binary Ninja text, whose ebp_1 aliasing in this function is visibly corrupt.
THREE copies were deleted, not one. The AP-22 register row cited
LiveEntityCollisionBuilder.cs and ShadowShapeBuilder.cs; the latter never
reads Setup.Radius at all, and the row omitted both
LandblockPhysicsPublisher.PublishStaticEntity and
LandblockPhysicsContentBuilder.PublishStaticCollision -- the second being the
only copy the headless host executes. Fixing just the cited site would have
left headless statics on the invented footprint.
The branch was unreachable dead code, not a live approximation. A sweep of all
5,935 Setups in the installed client_portal.dat -- validated by byte
accounting (5,935/5,935 records consumed with an exact 20 + 48*numLights
residual tail, zero unexplained bytes) and independently reproduced by the
production FlatCollisionAssetBuilder.FlattenSetup path -- finds 0 Setups
satisfying the guard: every Setup with Radius > 0.0001 carries at least one
CylSphere or Sphere, and all 1,294 genuinely shapeless Setups have Radius
exactly 0. Buckets: 678 cylsphere, 3,605 sphere-only, 358 BSP-only, 1,294
shapeless, 4,282 with Radius > 0.0001. Nothing loses collision because nothing
gained it, so no visual gate is required.
Tests, all sabotage-verified in both directions:
- InstalledSetupCollisionReachabilityTests (new, Content) -- the negative
claim plus five EXTERNAL positive controls, so a broken enumeration cannot
satisfy it vacuously. Inverting the claim reddens it; emptying the
enumeration fails on the controls at 0 != 5935 rather than passing.
- ShapelessSetupWithRadius_ProducesNoRegistration (new, App) -- restoring the
deleted block reddens exactly this fact and nothing else.
- Build_PropagatesExactStateFlagsScaleAndFullSeedCell -- re-hosts the state /
PWD-flag / seed-cell coverage that rode on the deleted fallback test, whose
fixture (a Setup with a radius and no primitives) cannot exist in the DAT.
Flipping a FromPwdBitfield bit reddens it; so does swapping SeedCellId for
the landblock id.
Also corrects ShadowShapeBuilder's retail-anchor comment, which claimed each
part's find_obj_collisions tests "CylSpheres + GfxObj BSP".
CPhysicsPart::find_obj_collisions @0x0050d8d0 tests ONLY the GfxObj physics
BSP; CylSpheres are a Setup-level array reached via CPartArray::GetCylsphere.
That comment was the written justification for the additive emission now filed
as AP-152, so it is corrected here even though AP-152 is not fixed here.
AP-22 retired with evidence; AP-152 filed (live path emits primitives AND BSP
parts additively where retail is exclusive -- 172 of 5,935 Setups including
BSP doors; deliberately not folded in, it needs its own visual gate). Issue
#330 filed: the headless host registers no live-entity collision at all, a
pre-existing gap this survey established and nothing tracked.
Gates: Release build 0 errors / 0 warnings. Complete solution suite
11,195 passed / 4 skipped / 0 failed (baseline 11,193/4/0 at bcb66ccd; +1 App
for the added fact, +1 Content for the reachability test; the replaced test is
net zero). No new skips. Headless.Tests 89/89 exercises the site-3 copy.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Both items come from the D-1 fix review (both lenses PASS, D-1 genuinely
closed). No production behaviour changes.
L1 — THE SEAM HAD NO COVERAGE. The D-1 fix's "empty by construction" claim
rests on LandblockSpawnAdapter's atlas-tier filter (`if (entity.ServerGuid
!= 0) continue;`) skipping the live server projections DetachNearLayer
deliberately RETAINS across a demote. The reviewer removed that filter and
all 4,170 App tests passed — only two Core unit tests caught it, none
through a demote. So the invariant the re-assert depends on could have been
deleted silently, re-opening D-1 by another route: a non-empty re-assert
whose mesh reference is never satisfied leaves IsRenderReady false, which is
the portal hang again.
NearToFarDemote_WithALiveServerEntity_StaysRenderReady now demotes a
landblock that CARRIES a live server-spawned entity through the real
GpuWorldState + LandblockSpawnAdapter + LandblockPresentationPipeline, and
asserts the retained entity never enters the desired set.
Sabotage-verified: with the filter removed, exactly one test fails — this
one — and the other 25 pass, including all four D-1 regression tests. That
is the finding restated as a measurement: the D-1 tests genuinely do not
cover this seam, and now something does.
AP-150 citation corrected: the row cited 0x004D7064 as the
ECM_UI::SendNotice_DisplayStringInfo call site. That address is the
PStringBase construction of the "In Portal Space - Please Wait..." literal
(:219516); the actual call is 0x004D70A1 (-> 0x006925B0). Same class of slip
the #280 commit had just corrected for #326 — worth noting that a row filed
WITH a byte-level disassembly still mis-cited a neighbouring address.
Also refactored the existing pipeline demote test to keep its doc comment
attached to its own method (an earlier insertion had orphaned its [Fact]).
App.Tests 4,170 -> 4,171 passed / 3 skipped, net +1 for the new test. No new
skips; none of #302/#308/#321 surfaced. src/ is byte-unchanged (the sabotage
was reverted and verified).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
User decision 2026-08-06. #309 moves from OPEN to DEFERRED/ACCEPTED, and
AP-136 becomes its permanent record rather than a staging note. No code
changes.
No new register row was filed: AP-136 already carries the full retail
mechanism (SetPositionInternal 0x00515BD0 -> store_position 0x00515CE2 ->
GotoLostCell 0x00515CF2, removed only by InitObjCell 0x00508260 ->
reenter_visibility 0x00516250), the exact divergence, and the observable.
Filing a second row would have duplicated it.
WHY DEFERRED, recorded so a successor does not silently re-litigate it. The
retail-faithful end state is a park that SURVIVES cancellation. That was
implemented and reverted this round, because it costs (a) reversing a
deliberate shipped invariant —
NewerPositionPickupAndParentEachCancelExactLostOperation asserts that a newer
Position cancels the park — and (b) GameRuntime teardown convergence (stage
10), where surviving parks never converge on shutdown. The observable
requires a remote to teleport into a non-resident landblock AND then stop
moving; ACE stops broadcasting for a stationary entity, while the ordinary
5-10 Hz case is superseded within ~150 ms. Revisit if teardown convergence is
done for another reason, or if the observable is reported in ordinary play.
CAUGHT WHILE RECORDING IT: deferring the fix does NOT cancel AP-136's
six-step connected check. That check validates the SHIPPED rollback path
(#312 / restorableOnCancel, which sits in SubmitPreparedPlacementCore — the
shared core behind every production placement), not the deferred fix. It
still needs running with ACDREAM_PROBE_PARK=1, and therefore must run BEFORE
C5c's probe strip retires that flag. Both documents now say so; without that
note the strip would have silently removed the instrumentation a still-owed
gate depends on.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Both #280 review lenses returned FAIL on the same defect, and both were
right. IsRenderNeighborhoodResident's widened outer arm requires
IsRenderReady out to FarRadius, justified by "a Far-tier landblock
registers with an empty mesh set and is therefore render-ready." That held
only for a landblock that ARRIVED as Far. The second, equally first-class
way to be Far tier is a Near->Far DEMOTE:
DemoteLandblock -> EnqueueNearLayerRetirement
-> LandblockRetirementStage.MeshReferences
-> GpuWorldState.ReleaseLandblockMeshReferences
-> LandblockSpawnAdapter.OnLandblockUnloaded => WantsLoaded = false
while DetachNearLayer deliberately keeps the landblock loaded, terrain-mesh
resident, terrain-collision resident and DRAWN. Nothing re-publishes an
already-loaded landblock, so the demoted member satisfied NEITHER arm of
the gate, permanently: wormhole tunnel plus centered "In Portal Space -
Please Wait..." forever, no recovery short of relog.
Reachable by ordinary play. Two consecutive recalls to the same landblock
with walking in between makes ChangesStreamingCenter false, so there is no
origin recenter and the region recentres through the ordinary demote diff.
Also reachable via a mid-hold quality-preset drop -- ironically the exact
scenario ReconcileDestinationReservationRadius was added to support. The
pre-#280 radius-1 gate never touched that band, because nothing inside the
Near ring can demote.
FIX SHAPE. Make the two routes genuinely equivalent rather than teaching
the predicate to tolerate the difference. ReleaseLandblockMeshReferences
becomes "reconcile the registration to the post-retirement tier": after the
release converges, if the landblock is still loaded AND still Far tier,
re-assert the empty registration -- the identical OnLandblockLoaded(lb,
empty) a PublicationKind.Far activation makes. It is empty by construction:
DetachNearLayer retains only live server projections, which the adapter's
atlas-tier filter skips. A full retirement is unaffected (DetachLandblock
clears both _loaded and _tierByLandblock), and a throwing release still
retries because the re-assert is only reached after the adapter converged.
The alternative -- "|| (IsFarTier && IsLoaded)" at the gate -- was
rejected: it fixes one caller while leaving IsRenderReady meaning two
different things, which is precisely how this defect arose. After this
change the predicate reads "drawable at its current tier" for every caller,
with no knowledge of how the landblock got there.
WHY THE TESTS MISSED IT, fixed here too:
- Proof obligation P2 was discharged against RESIDENCY (the FarRadius+2
eviction threshold) rather than against IsRenderReady, the gate's actual
atom. The contract now carries the correction and the restated
obligation: no transition may REVOKE IsRenderReady from a landblock that
stays inside FarRadius.
- WorldRevealDerivedWindowIntegrationTests advertised itself as end-to-end
against the real GpuWorldState but constructed it with no spawn adapter,
so its IsRenderReady degenerated to IsLoaded via the "?? true". The
single most load-bearing predicate in the change was stubbed out by a
null in the test named after it -- the same shape as C5b's D3 and #276's
three settler tests. Every fixture in that file now owns a real
LandblockSpawnAdapter.
- The P1 test's comment described its subject as "a Near-shaped completion
the streaming window has since DEMOTED to Far". It is not; it is a fresh
PublishAsFar, the case that does hold. Corrected, since a future reader
would have taken it as demote coverage.
Four new regression tests, all driving the real GpuWorldState +
LandblockSpawnAdapter + LandblockPresentationPipeline through an actual
demote, and all sabotage-verified in both directions (fail with the
production change reverted, pass with it):
NearToFarDemote_LeavesTheLandblockRenderReadyThroughTheRealPipeline
NearToFarDemote_LeavesTheLandblockRenderReadyUnderBudgetedRetirement
TieredWindow_StaysResidentAfterAnOuterRingDemote
OutdoorReveal_SurvivesAnOuterRingDemoteDuringTheHold
The budgeted variant exists because production composes
LandblockRetirementCoordinator.CreateBudgeted, whose MeshReferences stage
is a separate call site from the legacy pipeline's.
SECONDARY, same commit:
- R-1: ACDREAM_PROBE_REVEAL_RADIUS=0 was parser-accepted and
Runtime-rejected -- it yields far = 0 for an outdoor destination, which
fails invalid-readiness-shape on every acknowledgement, hanging the very
A/B route the probe exists to measure. Parser floor raised to 1, with a
7-case table test.
- R-2: the composite-warmup TRIGGER had silently moved onto the far
window's critical path. Pre-#280 the gate and the composite domain were
the same radius-1 square; #280 widened the gate without widening the
domain, so every composite upload serialised behind the last outer-ring
landblock for no readiness benefit. Warmup now starts once the NEAR
sub-window is published -- trigger scope == domain scope, as before. The
reveal gate is untouched: Evaluate still requires the full window AND
composite readiness.
- AP-150 filed: acdream's RetailWaitCueDelay = 5 s arming is NOT retail's
trigger, and #280's commit message got this wrong on both clauses. Retail
emits the notice unconditionally per tunnel rotation segment, in the else
arm of the segment-expiry test at 0x004D6FCD; segment duration is
RandDouble(0.6, 1.8) s, byte-decoded at 0x004D6FE6. The 5.0 constant at
VA 0x007991B0 is CellManager::CheckPrefetchStatus's prefetch RETRY
cadence and has nothing to do with the cue. acdream's own 0.6/1.8 segment
constants already match retail exactly; only the arming is wrong.
Adopting retail's unconditional emit is filed as #329 rather than folded
in here -- it is a user-visible presentation change and wants the user's
eyes.
- AP-151 filed: the gate is materially STRICTER than retail on the
mesh-build/GPU-upload axis. Retail's LScape::PreFetchCells blocks on DAT
RESIDENCY only -- no geometry construction, no upload; that work is lazy
at draw. acdream requires a DAT read, terrain mesh build, render-thread
upload, spatial commit, collision admission and spawn-adapter activation
per member of a 625-member window, metered at MaxCompletionsPerFrame.
Nothing bounds the hold. This is the OPPOSITE asymmetry from AP-149; both
are live at once, on different axes.
- AD-2's amendment stated the false Far-tier readiness assumption verbatim;
corrected, along with the same error in
claude-memory/reference_two_tier_streaming.md, which now carries an
explicit DO-NOT-RETRY on the special-case-the-predicate shape.
- AP-115 scope-noted (it covers the cue's presentation, not its arming).
- #326's SmartBox::set_mid_radius citation corrected: the entry is
0x00453180; 0x004531D0 is the mid-function re-arm branch.
Blast radius: GpuWorldState, LandblockSpawnAdapter,
WorldRevealReadinessBarrier and StreamingDiagnostics are all App-internal;
AcDream.Headless and AcDream.Runtime reference none of them outside
comments. Headless tests run green as part of the gate below, per C5b's
lesson about surveys that skip the no-window host.
Gates: Release build 0 errors, 18 pre-existing xUnit analyzer warnings.
Complete suite "dotnet test AcDream.slnx -c Release -m:1" with
ACDREAM_PAK_PATH set: 11,192 passed / 4 skipped / 0 failed, from a clean
rebuild (a prior session's deleted probe file had been compiled into a
stale test DLL). Baseline at fafc0b65 was 11,179 / 4 / 0; the +13 delta
reconciles exactly to this commit's additions -- 3 readiness tests, 1
integration test, 7 parser table cases, 2 warmup-trigger tests. None of the
known flakes #302/#308/#321 surfaced, and none is conflated with the
finding above.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
SpawnPlacementSettler committed settle.Position but discarded settle.CellId,
so a compressed first-gravity-frame settle that crossed a cell boundary left
the body's cell at the placement cell until some later resolve corrected it.
Now committed through the same guarded channel the per-tick resolve writeback
uses (RuntimeOrdinaryPhysicsUpdater): resolved cell when the transition
reports one, source cell otherwise, never a zeroed residency.
Retail anchor: CPhysicsObj::SetPositionInternal(CTransition const*)
0x00515330 commits both sphere_path.curr_pos.objcell_id and its frame,
including EnvCells.
WHERE THE DEFECT ACTUALLY BIT — #276's own framing is half wrong, and the
half it misses is the whole fix. PhysicsBody.Position's setter already
mirrors the world delta into the landblock-local frame and lets
LandDefs.AdjustToOutside recompute the 24 m cell index from it, so an
outdoor->outdoor settle already landed the right cell and dropping
settle.CellId cost nothing there. It cannot do that for an EnvCell: an
EnvCell id is not derivable from a position, so the mirror deliberately
PRESERVES it. settle.CellId is therefore the only carrier of a cell identity
across an indoor seam. The live defect is the issue's parenthetical
("outdoor/EnvCell seam, stacked EnvCells"), not its main clause — and the
change is consequently a no-op on the outdoor path that dominates
production, corrective only at the seam.
That finding is what made the test possible. The three existing settler
tests build bodies with NO CellPosition and pass identically with or without
this change — shipping against them would have repeated C5b finding D3, a
test that passed with its own change reverted. The new test seeds an EnvCell
id over plain outdoor terrain instead, so the stale-id preservation is the
discriminator and no EnvCell geometry fixture is needed.
Sabotage-verified: restoring `body.Position = settle.Position` fails exactly
the new test (1 failed / 4) and leaves the other three green — confirming
both that the new test discriminates and that the old ones never could.
Core suite 4,263 passed / 1 skipped / 0 failed, +1 for the new test.
Still open and unverified, deliberately not claimed closed: whether the
remote spawn-seed caller (LiveEntityNetworkUpdateController) hands in a body
that carries a CellPosition at all. CommitTransitionPosition early-returns on
a zero cell, so this fix is an inert no-op there and #276's remote half may
survive. The C3c local first-entry caller is confirmed — it passes
activation.Body.CellPosition.ObjCellId. Scoping detail in
docs/research/2026-08-06-276-remainder-scoping.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Analysis only. A candidate fix was written, built clean and passed the
three existing settler tests, then deliberately REVERTED — the only test
that discriminates it needs an EnvCell fixture that was not safe to
assemble at the end of this session. The production tree is unchanged.
Headline: the issue's framing is half wrong, and the half it misses is the
whole fix. PhysicsBody.Position's ordinary setter already carries the world
displacement into the landblock-relative frame AND calls
LandDefs.AdjustToOutside, which recomputes the outdoor cell index across
24 m cell crossings and wraps/bumps the landblock across 192 m boundaries.
So for an outdoor->outdoor settle, discarding settle.CellId costs nothing.
The live defect is EnvCells. An EnvCell id is not derivable from a world
position, and AdjustToOutside's guard ((cell & 0xFFFF) is >= 1 and <= 0x40)
deliberately excludes EnvCell ids from that path. settle.CellId is the ONLY
carrier of an EnvCell identity, and it is exactly what the settler drops —
so the defect is the issue's parenthetical ("outdoor/EnvCell seam, stacked
EnvCells"), not its main clause. That also means the fix is a no-op on the
outdoor path that dominates production and corrective only at the indoor
seam.
Recorded so the next reader does not repeat the misreading I made:
CommitTransitionPosition looks like it pairs a new cell with a stale local
origin, but line 259's `Position = worldPosition` runs the ordinary setter
first, so line 265 reads the already-updated origin. Retail anchor
CPhysicsObj::SetPositionInternal(CTransition const*) 0x00515330 commits both
objcell_id and frame, including EnvCells.
Also recorded: the three existing settler tests build bodies with no
CellPosition, so every one passes identically with or without the fix.
Shipping against them would repeat C5b finding D3 — a test that passed with
its own change reverted. The doc carries the exact discriminating test, its
required sabotage, and the fixture risk (the resolver must genuinely report
the EnvCell in settle.CellId; a fixture that silently resolves outdoor would
be green and prove nothing).
Open and unverified: whether the remote spawn-seed caller's body carries a
CellPosition at all. CommitTransitionPosition early-returns on a zero cell,
so the fix would be an inert no-op there and #276 would stay open for
remotes. Must be settled before claiming the fix closes both halves.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Report-only per CLAUDE.md's investigation rule; no fix applied and none
approved. The call at LiveEntityNetworkUpdateController.cs:2459 is
untouched.
Verdict: velocity is a SEPARATE WIRE CHANNEL in retail, and acdream's
accepted-Position path crosses it.
SmartBox::DoVectorUpdate 0x004521C0 is retail's sole velocity installer
for a remote (set_velocity 0x0045221E + set_omega 0x0045222C), gated on
update_times[3] = VECTOR_TS — not Position's update_times[0]. An
exhaustive grep of its call sites returns exactly two, and neither is the
Position path: SmartBox::HandleVectorUpdate 0x00453480 (call 0x004534E6)
and SmartBox::HandleCreateObject 0x00454C80 (call 0x00454EE9).
HandleReceivedPosition's only set_velocity is 0x004541B4, which ZEROES the
local player on the teleport arm.
Retail is not merely silent here, it is deliberate: PositionPack::UnPack
0x00516740 does decode a velocity off the Position wire (field written
0x005167E9) — retail receives the value and drops it on this path.
acdream instead commits acceptedSpawn.Physics?.Velocity on every accepted
Position, and the retail-correct mechanism ALREADY EXISTS one method away
(TryCommitAuthoritativeVector, whose doc comment describes DoVectorUpdate's
exact paired shape). The Position-path call is therefore both non-retail
and redundant with a correct sibling. Sharpening the divergence: the call
passes `?? Vector3.Zero`, so a Position without HasVelocity actively zeroes
the body — something retail never does on this path.
Recommended (NOT approved): either remove the call, or keep it and file a
register row as a deliberate adaptation in AP-135's class. Three unresolved
inputs decide which, listed in the report's section 5 — chiefly what
consumes body.Velocity for a remote (AP-80's velocity-derived animation
cycle is the specific unknown), and whether ACE sets HasVelocity at all. The
retail half of the audit is settled; those three are cheap follow-ups that
do not need the binary again.
Successor note: this function family carries Binary Ninja's dropped-flag
artifact (`-((eax_4 - eax_4))` at 0x004521F5 and 0x00452186), the same one
the C5b review hit in Gate A. Do not read a comparison here from pseudo-C.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Report-only per CLAUDE.md's investigation rule; no fix applied and none
approved. Committed so the evidence is not lost.
Verdict: the player arm's airborne-snap block skips the collision-shadow
publish, but the stale shadow self-heals within one object quantum
(~33 ms). Not the #184 invisible-but-solid class.
The reasoning is structural rather than incidental, which is why it
resolved offline instead of needing a connected sample. The per-tick gate
at RuntimeRemotePhysicsUpdater.cs:840 compares the body against
LastShadowSyncPos/Orientation, and those fields are stamped ONLY inside
SyncRemoteShadowToBody immediately after a publish. They therefore record
where the shadow actually is, which makes the gate an invariant check
("is the shadow more than 1 cm / 0.51 degrees from the body?") rather than
a change-detector. The snap's two raw field writes leave that invariant
violated and untouched, so the next quantum sees the full delta and
republishes.
Three findings beyond the question asked:
- One residual does NOT self-heal: past 96 m the activity gate deactivates
the remote while OnPosition is not distance-gated, so a distant
player-remote's render entity moves and its shadow does not, until it
re-enters the bubble. Unobservable in practice — everything that could
sweep against it is gated by the same rule.
- The "LANDING TRANSITION" naming throughout the file is stale: the
predicate is !Body.InContact, the whole airborne period, so it fires on
every airborne update rather than once at the landing edge.
- RuntimeSetPositionState.cs:5037 stamps LastShadowSyncPosition before a
guard at :5138 that can return ahead of the publish at :5148 — a
possible masking hole, deliberately not folded in.
Retail note: retail has no separate shadow at all — SetPositionInternal
0x00515330 calls remove_shadows_from_cells/add_shadows_to_cells in the
same transaction, so the skip is a real divergence, just a 33 ms one.
Recommended next step (NOT approved): an offline two-step test composing
the collapse-matrix player-guid landing fixture with one Tick, asserting
the shadow converges. Strictly stronger than a connected sample, which
could only show that nobody noticed 33 ms.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The user watched far terrain visibly assemble after portal space exits.
The reveal gate was NOT missing a hold — Slice E's hold mechanism is
correct and already in place. The hold was measuring the wrong domain:
it opened at a hardcoded 3x3 landblock neighbourhood (~192 m) while the
visible world extends to the fog end (~2,189 m at the shipped High
preset, inside a 2,304 m Far window). An 11.4:1 ratio.
Retail's equivalent ratio is 1:1 BY CONSTRUCTION. `LScape` owns one
`mid_width x mid_width` array of `CLandBlock*` (`LScape::SetMidRadius`
@0x00504C00, `LScape::update_block` @0x005063A0), `mid_radius` is
assigned directly from the user's `Render.LandscapeDrawDistance`
preference (`SmartBox::SetRegion` @0x004531F0; values
`Render_LandscapeDrawDistance_Values` @0x007CA988 = {3,5,8,11,15,25},
default 8 — both byte-verified against the PDB-paired 2013 binary), and
that same square is simultaneously the prefetched set
(`LScape::PreFetchCells` @0x00505660), the drawn set (`block_draw_list`
over the same array), and the set the simulation blocks on
(`CellManager::blocking_for_cells`). There is no retail configuration in
which the client streams farther than it gates, because there is only
one number.
So the fix derives rather than duplicates. Four coupled parts, which is
why this is one commit and not four — D1 without D2 hangs the client and
D2 without D1 is dead code:
D1 `WorldRevealReadinessBarrier` takes a live `Func<StreamingRevealWindow>`
and stops being static: outdoor requires `FarRadius`, indoor still 0
(retail's `CEnvCell::PreFetchCells` @0x0052D1E0 arm). Read per
evaluation, never captured — the radii are runtime mutable through
Settings, and retail's answer to a mid-hold radius change is to reset,
re-radius, and re-arm the blocking prefetch at the NEW value
(`SmartBox::set_mid_radius` @0x00453180). `OutdoorNeighborhoodRadius`
is deleted; there is no constant left to drift.
D2 `StreamingController.IsRenderNeighborhoodResident` becomes tiered,
because acdream's loaded landscape is: inside `NearRadius`,
`IsNearTier && IsRenderReady`; out to `FarRadius`, `IsRenderReady` only.
Without this the fix cannot work at all — nothing outside the Near ring
is ever promoted, so any radius above `NearRadius` was unsatisfiable and
would have held the reveal forever. Proof obligation P1 (a Far-tier
landblock genuinely satisfies `IsRenderReady`) is now a test driven
through the real `PublicationKind.Far` pipeline against a real
`LandblockSpawnAdapter`, not an inference.
D7 `RuntimeWorldTransitState.AcknowledgeDestinationReadiness` re-derived
`indoor ? 0 : 1` and failed `invalid-readiness-shape` on any other
value, so changing the radius alone would have looked like "the fix
hangs the client". It is now a SHAPE invariant (`indoor => 0`,
`outdoor => >= 1`). Runtime does not own the graphical host's streaming
configuration and must not learn it; plumbing App radii into Runtime to
preserve the strict equality is exactly the assert-a-mechanism-that-does-
not-exist failure C5b was built to stop. Both non-graphical producers
keep emitting their centre-ring token and stay legal, annotated in place.
D6 `PhysicsEngine.IsNeighborhoodTerrainResident` rebuilt a full-map
`HashSet` on every call, every frame of every hold. At radius 1 that was
invisible; at radius 12 (625 ring members) it violates Slice I1's
0 B/resolve standard. Now an engine-owned scratch set, cleared in place;
measured at 0 bytes over 1,000 warmed radius-12 queries.
Also: the destination reservation opens at exactly the gate's radius and
reopens on the same generation when the radius changes mid-hold (retail
has one square for both, and no concept of prioritising an inner ring
differently). Composite warmup deliberately stays `NearRadius`-scoped —
the composite domain is entity-scoped and Far builds carry no entities,
so widening it would walk the outer window to warm nothing.
`ACDREAM_PROBE_REVEAL_RADIUS` is a measurement probe in a diagnostic
owner (CLAUDE.md rule 5) so the connected route can be run A/B on one
binary; it is NOT a user-facing prefetch knob, since a low setting would
reintroduce the decoupling this slice exists to close.
Register: AD-2 amended with the derived window, the two-tier split, and
the four new retail anchors. AP-149 FILED for the residual this does not
close — the outer ring accepts terrain-only publication where retail
requires LandBlockInfo and every building EnvCell, so a distant building
can still pop in at Far-ring distances. Do not let a later closeout
claim parity.
Docs: `ACDREAM_STREAM_RADIUS`'s CLAUDE.md description was wrong on every
clause (the default is unset, not 2; it forces `NearRadius`; it is
silently discarded by any Settings save) — corrected, since that is the
file every session reads. `reference_two_tier_streaming.md` corrected in
four ways, including "Far tier = terrain only": Far also publishes
terrain COLLISION, which is precisely what makes this fix viable.
#280's issue text had the right conclusion from a wrong premise (it
names a view-distance setting acdream does not have) — corrected, and
the missing Viewing Distance option filed separately as #326, with #327
(DDD progress readout) and #328 (hardcoded 5000 f far plane vs retail's
byte-verified 4000) filed alongside.
Expect LONGER holds and the "In Portal Space - Please Wait..." cue on
recalls MORE often. That is convergence toward retail, not away from it:
retail emits the byte-identical string for the whole duration of a
blocked prefetch and polls at 5 s intervals. The failure condition is
non-convergence, not duration.
Gates: Release build 0 errors. Complete suite 11,178 passed / 4 skipped
/ 0 failed, against a re-measured 11,142 / 4 / 0 baseline at 9ee9c1a1 —
+36, reconciled exactly as 36 new tests (App +23, Runtime +10, Core +3),
zero deleted, zero newly skipped. Nine discriminating tests
sabotage-verified in both directions. The connected/visual gate is
batched into C5's matrix; its recipe, its three positive artifacts, and
its required recall leg are written into the campaign plan.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Both C5b re-reviews returned PASS on 02578441..ff100cf3. This lands the
bookkeeping corrections they left, the one gate asymmetry both found
independently, and one wrong retail fact neither of them caught.
1. AP-148 / #325 — Gate A's teleport test, wrong on primary source twice.
The C5b contract stated retail's Gate A teleport term as "TELEPORT_TS
equal" (and, in the trace block, as "must NOT be newer") and blessed
acdream's `teleport == _timestamps[Teleport]` as retail-exact. Disassembly
of the PDB-paired binary at SmartBox::HandleReceivedPosition
0x0045402B-0x00454054 says otherwise: the shortcut is taken iff the wire
stamp is equal OR newer (wrap-safe) — `sbb eax,eax / neg eax` materialises
the carry of the compare and the branch skips Gate A on CF, i.e. only when
the wire stamp is strictly OLDER. It is CPhysicsObj::newer_event
@0x00451B10's identical idiom with the operands swapped. Binary Ninja drops
the flag test and renders it `if (-((eax_7 - eax_7)) == 0)`, always true —
which is why two rounds of reading pseudo-C recorded it backwards.
So acdream's ForcePosition disposition is a strict SUBSET of retail's Gate
A set, and a local ForcePosition carrying a newer teleport stamp is
misrouted into a full Apply: wire heading instead of preserved heading,
unparent, possible placement frame, zeroed velocity, TELEPORT_TS advanced,
and OfferTeleportDestination called for a packet retail never starts
presentation for.
PhysicsTimestampGate.cs is NOT changed. The predicate exists twice (also
ValidAcceptedAuthority's PreviousTeleport == AcceptedTeleport), and the fix
has to decide TELEPORT_TS's disposition on a Gate A path that has never
seen a stale-but-equal pair. #325 records all of it and says explicitly
that it is not a one-line comparison swap. C5b made this marginally
better, not worse: clearParent was unconditionally true before C5b and is
unchanged; installPlacementFrame moved toward retail's HasAnims gate.
2. Retail F2 / architecture L-A — the no-window route had no pre-merge
payload validation. Root fix, not a documented asymmetry.
The graphical route validates before the merge (OnPosition's payloadIsValid
-> LiveEntityInboundAuthorityGate's !payloadIsValid return); despite its
name CanAcceptPositionPayload is not projectile-scoped. The no-window route
had no equivalent, and since D1 fed an unvalidated LandblockId into
CommitWireCellRebucket — where 0 is the withdrawal shape, silently
de-residencing the entity in the field every bot reads as CellId.
RuntimeLiveEntitySessionController.OnPositionUpdated now applies the same
rule at the same point, reusing
RuntimeAuthoritativePositionRouteClassifier.IsValidCreateWirePosition plus
the finite-velocity term — the exact pair TryApplyPosition already applies
on its initial-residence branch. Chosen over documenting it because the fix
is five lines and leaving it would have left two written claims falsified
by the code. It is a behaviour change: headless now drops packets it
merged. Against ACE the set is empty, and the graphical host has carried
this gate since it was written; the argument is recorded in the contract's
§15.2 rather than gated.
Two test fixtures carried cell ids retail's own inbound_valid_cellid
rejects (low words 0x41 and 0x51, above the 0x40 landcell ceiling). Their
constants were corrected; their assertions were not.
New test sabotage-verified in both directions: gate removed -> red at the
withdrawal-shape assertion; gate moved to guard only the cell commit ->
red at the pose assertion, which is what makes it a before-the-MERGE test
rather than a before-the-commit test.
3. Register and doc corrections.
- AD-64: "deliberately absent" was presented as the complete difference
list and was not. Adds (a) the residence gate is weaker than the merge's
own — both hosts' commits use TryGetCurrent while TryApplyPosition's FIFO
branch uses TryGetTransaction, so the wire cell can commit ahead of the
continuation that will replay it; (b) the two missile gates are two
different expressions that agree today; (c) the payload gate, now
present. Risk column records that (a) and (b) have no discriminating test
on either side.
- AP-147: amended for D1 — pre-D1 the no-window host published [Updated]
alone and lost the Rebucketed, so a headless event log is now a real
instance of the "consumer that snapshots a delta" the row warns about.
- AD-60: "Matches retail exactly" scoped to the withhold, since the row's
body documents two channels that do not.
- CommitWireCellRebucket: notes the unreachable ThrowIfNull /
EnsureNotDisposed precedence inversion.
- TryCommitAcceptedWireCell: the discarded commit bool is explained rather
than left bare — false means IsCurrent went stale, unreachable three
statements after a synchronous TryGetActive.
4. Bisect hazard recorded in the C4 closeout handoff (the doc CLAUDE.md
sends readers to before any C5 work) and in the contract's §15.3: commits
735f0a72..23aa62f2 contain a live headless defect — every remote's
FullCellId frozen for the session — introduced by 735f0a72 and fixed only
at ff100cf3. Nothing throws and no test in the range fails.
Gates: Release build 0 errors/0 warnings. Complete suite 11,142 passed /
4 skipped / 0 failed against the 11,141 / 4 / 0 baseline — net +1, exactly
the one new test. No flake appeared (#302, #308, #321 all green).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
C5b (735f0a72) made the steady-state accepted-Position merge stop writing
residency. That is retail-correct — HandleReceivedPosition @0x00453FD0 reads
the wire objcell_id into a local and never assigns the object's cell — and it
stays. What C5b did not account for is that its replacement writers both live
in AcDream.App: the OnPosition prologue rebucket (AD-60's W2) and the
post-routing wire-cell adopt (W3, AP-135).
The two hosts run parallel, non-shared inbound routes. LiveEntitySessionController
-> LiveEntityNetworkUpdateController.OnPosition is graphical-only;
RuntimeLiveEntitySessionController.OnPositionUpdated is the no-window route and
is constructed only at HeadlessSessionHost.cs:682. So AcDream.Headless had NO
post-merge cell writer at all. Every remote's FullCellId was written at
create/placement and then frozen for the session — and RuntimeEntityObjectViews
.Snapshot projects exactly that field as RuntimeEntitySnapshot.CellId, i.e. every
bot's entire world view. The local player lost one of AP-146's three refresh
edges, which matters beyond cosmetics: RuntimeSetPositionState
.IsAffectedCollisionResident reads FullCellId to pick which bodies a landblock
retirement parks, so a bot running A->B without teleporting would have retired A
while parking a body physically in B.
The fix, in three parts:
1. RuntimeEntityObjectLifetime.CommitWireCellRebucket — a new Runtime owner for
the committed VALUE, extracted verbatim from LiveEntityRuntime
.RebucketLiveEntity. This is also the root-cause fix for the layering
inversion the review found: AD-60 was documenting its own correctness by
naming an App class the Runtime assembly cannot reference. Behaviour on the
graphical side is unchanged — record.FullCellId is a proxy for
record.Canonical.FullCellId, which is the record the callee reads, and the
commit is still CommitRebucket. Verified load-bearing for BOTH hosts:
sabotaging the preserve branch reddens the graphical
LiveEntityRuntimeTests.CanonicalOnlyRebucket_DoesNotOverwriteAuthoritativeFullCell
as well as the new headless assertion.
2. RuntimeLiveEntitySessionController.TryCommitAcceptedWireCell — the no-window
W2, under the same reachability rules the graphical route applies: Rejected
writes nothing (the shape the App authority gate produces by returning false);
a bound-projectile packet writes nothing (routed by the graphical host through
the canonical projectile placement owner, which returns before W2); an active
initial-create residence writes nothing (RebucketLiveEntity's own early
return — while the lease is live the SetPosition conductor is the sole cell
authority); a local ForcePosition writes only when the accepted-Position drive
declined it (NotApplicable), because a handled force is
placement-receipt-authoritative. W2/W3 themselves are untouched.
3. On the committed value (the landblock-vs-cell trap). RebucketLiveEntity's
preserve branch fires on a LANDBLOCK-shaped id — low 16 bits 0xFFFF — and
exists for LocalPlayerProjectionController.Project, the per-frame local
movement caller that emits exactly that shape. An inbound wire objcell_id is
never landblock-shaped, so on the accepted-Position route the branch is not
taken and the exact wire cell is committed. That is what W2 commits today and
what this now commits; the no-window host has no per-frame caller at all.
Ordering is matched, not improved on: the force drive submits its placement
before the commit, so its first submit still reads the pre-commit FullCellId —
AP-138's amended route-2 CurrentCellId measurement.
Bookkeeping in this commit:
- AD-60 corrected. Its surviving-channel enumeration presented "the local force
path, the missile arm" as exhaustive; the entire no-window host belonged in it.
23aa62f2's W2/W3-redundancy measurement is preserved verbatim.
- AP-146 and #320 amended the same way — their three-edge list was written from
the graphical host and silently assumed both hosts shared it. The no-window
host had two of three; it now has all three.
- AD-64 filed: the reachability decision is now expressed once per host. The
value is single-sourced; the gate set is not.
- #324 filed: unifying the two session controllers is the genuinely correct fix
and is campaign-sized (presentation recovery, hydration, the equipped-child
renderer, and the remote/projectile routing arms only one host has). Not
attempted here, per the fix brief.
Gates. Release build 0 errors. Complete suite 11,141 passed / 4 skipped /
0 failed, against the 11,134 / 4 / 0 baseline at 23aa62f2 — net +7, exactly the
7 tests added. Eight sabotages verified, each red on at least one discriminating
test and green when reverted: remote commit removed (2 Runtime + the end-to-end
Headless test); local ordinary commit removed; local NotApplicable-force commit
removed; force commit made unconditional; residence gate removed; missile gate
removed; Rejected gate removed; preserve branch broken (red on both hosts).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Follow-up to C5b (735f0a72). The retail-conformance review passed, so no
production behaviour line moved: the flag truth table and the
refreshPosition:false withhold are untouched. This is blast radius, test
discrimination and documentation fidelity - plus two findings I could not
confirm and am rebutting rather than complying with.
D3 - THE PUBLISH-CONSERVATION TEST DID NOT DISCRIMINATE. The reviewer was
right and it was the worst finding here: proof obligation 3's test passed
identically with C5b reverted. Its only delta assertion FILTERED
(Assert.Single(deltas, Rebucketed && parentGuid)), so the pre-C5b stream
[Rebucketed] and the post-C5b stream [Updated, Rebucketed] both satisfied
it, and childSpatialBefore+1 held in both worlds because whichever site did
not move the cell propagated idempotently. It now asserts the complete
ordered parent stream plus each element's CellId and Position.ObjCellId.
Sabotage: restoring refreshPosition:acceptedPosition turns it red (it was
green before), together with the withhold test and the new L5 test.
That cardinality change was itself unfiled and is now AP-147: a
cell-changing accepted Position publishes TWO entity deltas where it
published one, and the intermediate Updated pairs the OLD CellId with the
NEW wire Position - a torn pair that did not exist pre-C5b, since both
halves used to move inside one publish. No production consumer reads a
delta's paired fields, but a recorder/plugin/bot event log would capture
it. The row states why suppressing the Updated is not available at that
layer (the merge cannot know whether its caller reaches W2).
D4 - THE PROJECTILE DOC COMMENT WAS FALSE AND ITS RETAIL ARGUMENT WAS
INVERTED. SyncPresentationFromResolvedBody claimed record.FullCellId is
"the WIRE cell ... stamped by the merge's RefreshDerivedState/SetFullCell,
before classification ever runs" and argued from retail's store_position
@0x00515CE2 that the destination cell is the right one. C5b falsified the
premise; the missile arm also returns before W2, so nothing stamps the wire
cell for a projectile at all. Rewritten. The honest conclusion, which the
old text would have called wrong: on a stored outcome presentation now
pairs the DESTINATION world position with the SOURCE cell. That is not a
choice this method can make differently - StoreAcceptedDestinationPose
writes only Position/Orientation, so record.FullCellId and
body.CellPosition.ObjCellId now hold the same source cell and reading
either yields the same value. The divergence is AP-138 item (1)'s
store-writes-pose-but-not-cell residual, retiring via #309, not a field
choice here. Projecting the wire cell instead would invent a residency the
placement declined - the AP-1 shape C5b closed.
L3/L4/L5 - PINNING GAPS, ALL THREE CONFIRMED AND CLOSED.
L3: the matrix's oracle passed HasAnimations as a literal, so the merge's
old.MotionTableId ?? old.Physics?.MotionTableId and
RuntimeAcceptedPositionRouteRequests.Build's canonical-snapshot twin were
textually identical and pinned by nothing. The oracle is now BUILT by the
production constructor.
L4: every fixture set both MotionTableId halves to the same value, so
deleting either operand of the ?? was undetectable while the production
comment said the mixed case is the real-world one. Six mixed rows added,
including the explicit-zero row (a present-but-zero top half is not null,
so ?? never reaches the physics half).
L5: the retained Rebucketed ternary had zero coverage through
TryApplyPosition - every restoreCancelledPark test called Forget directly.
Now driven through the real merge, with the wire cell deliberately the
SOURCE while the park's committed body cell is the DESTINATION, so the
restored residency can only have come from the rollback.
Sabotage (each red, each restored): merge ?? -> top half only, 1 red;
-> physics half only, 2 red; Build's ?? -> physics half only, 2 red;
ternary -> constant Updated, exactly the L5 test red.
L1/L2 - THE MISSING TEST IS ADDED; THE DEFECT IS NOT THERE. The reviewer
was right that C5b's "no fixture covers pickup at that layer" was
inaccurate - LiveEntityNetworkOnPositionCollapseMatrixTests drives the real
OnPosition at ~26 sites - and the end-to-end test is added: withdraw ->
accepted Position -> IsSpatiallyProjected && FullCellId == wireCell, both
guid classes.
But ChildUnparentDisposition.Pending is NOT a live defect, because it is
production-unreachable. The sole production _withdrawProjection binding
(LivePresentationComposition.cs:599) is
LiveEntityProjectionWithdrawalController.WithdrawExact, whose only Pending
mint is inside its catch block and therefore always carries a non-null
Failure - and AdvanceUnparentTransition rethrows at
EquippedChildRenderController.cs:1307 BEFORE the return Pending at :1309.
The named drop scenario does not reach it anyway (BeginDetachedRemoval has
already emptied the capture list) and would be correct if it did: a
previously-equipped child is LegacyImmediate, so the FullCellId != 0u gate
at DatLiveEntityProjectionMaterializer.cs:767 is never consulted and
re-projection uses the wire cell at LiveEntityRuntime.cs:824.
Measured while building that test, and NOT what C5b assumed: W2 and W3 are
REDUNDANT on the remote tail. Sabotaging W2 alone - adopting the committed
cell instead of the wire cell, OR skipping the rebucket outright - leaves
the whole file green, because W3's RemoteMotion.CellId write reads through
to canonical FullCellId via CommitCanonicalCell, whose CellCommitted
recovery re-installs the bucket. Only removing BOTH goes red, and then the
new test is the only red in the file. So it is named for what it pins, and
AD-60 is amended with the measurement: neither channel is individually
load-bearing, so a future retirement of one is caught by nothing else.
D2 - REBUTTED, WITH THE REAL GAP FILED INSTEAD. The reviewer's hypothesis
was that TryApplyInitialCreateCompletionPresentation's staleness guard lost
its ability to detect an intervening steady-state Position when C5b stopped
the merge stamping the wire cell, and asked for a PositionAuthorityVersion
term. I do not think that is right and did not add it.
The receipt's facts are the canonical BODY's pose and cell at publish
(PublishExecutorCompletion builds both from the record). Exactly two owners
can move them: a Runtime SetPosition commit/withdrawal, every one of which
calls AdvancePlacementCommit - the only caller family is
RuntimeSetPositionState - and a rebucket, which moves FullCellId. Both are
already covered by the two existing terms. An accepted steady-state
Position is neither, and C5b did not make it one: the merge refreshes the
snapshot and advances PositionAuthorityVersion but never wrote the body,
and the App generic tail writes the RENDER entity. The wire-cell half stays
covered because W2/W3 commit it in the same call; the paths that return
before them leave the record at the last committed cell, which IS the
receipt's own cell - correctly not a supersession.
Adding the term would decline receipts whose facts are still true, on the
entity's FIRST world-visible moment: the pose write and
RebucketLiveEntityPresentationOnly would be skipped while TryPublishPlace
still publishes, so a packet returning before the render write would leave
the sidecar visible at its materialized pose in a wrong bucket. That is the
handoff's own "removed the invariant failure while leaving the bug" shape.
There IS one supersession neither term covers, and it predates C5b:
RuntimeRemotePlacementDriveController.StoreAcceptedDestinationPose writes
body.Position/Orientation on the far-snap Refused/Contention arm with no
placement commit and no cell move. Filed as #323 with the FIFO-blocking
argument for why a receipt can still be pending when it lands, an explicit
"not established as reachable", and an explicit "do not fix it with
PositionAuthorityVersion". The guard's comment now carries the whole
argument instead of one sentence.
S1 - DANGLING POINTER CLOSED. InboundPhysicsStateController.cs:610 still
said the two-callers-one-rule debt was "tracked for the eventual cutover
unification ... See docs/ISSUES.md", which pointed at nothing after C5b
closed#275 without a successor. Filed #322, cited from both the comment
and #275's closure, including why widening TryApplyPosition's signature to
take a route would be the wrong unification.
AP-138 amended: C5b staled its round-3 measurement that "both
accepted-Position callers commit the accepted wire cell to
record.FullCellId before submitting". Route 2 submits from
TryExecuteAcceptedLocalPosition ahead of W2, so on a first submit
PlacementTouchesPrefix's CurrentCellId arm now names the SOURCE landblock,
not the destination. Confined to which prefix the quiescence pre-flight
matches, which that row already established is not the correctness
mechanism.
GATES. Release build 0 errors. Complete suite 11,134 passed / 4 skipped /
0 failed, from the 11,125 / 4 baseline at ed806997: net +9, all new tests,
no test deleted or weakened, no new skip. Runtime.Tests 1195 -> 1202 (+6
mixed-motion-table rows, +1 park-rollback fact); App.Tests 4132 -> 4134
(+2 guid rows). None of #302/#308/#321 appeared. Not connected-gated -
nothing here changes runtime behaviour.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
C5b deleted AP-131's row outright and recorded the retirement only in the
AP section header, describing that as house style. It is not: the register
carries 32 struck-through `~~ID~~` retired rows, including AP-1 and AP-145
retired one commit earlier in C5a, and the C5b contract explicitly mandated
"rewrites the row's text to past tense with the evidence ... which the
C5a/AP-1 and 4b-3/AP-137 rewrites established as the house style".
A deleted row loses the evidence a header clause cannot carry, and leaves a
reader who greps AP-131 with nothing rather than a retired row. Restored in
the AP-1 shape, with one fact the header omitted and that is worth keeping:
this row's own predicted retirement mechanism did not occur. AP-131 forecast
"the legacy caller is deleted at the production cutover, retiring this row by
construction" — the caller was corrected instead, and the steady-state merge
remains a live production Position caller. That is the kind of prediction the
register exists to be honest about.
Active-row count is unchanged at 100; struck-through rows are retired, not
active.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The steady-state accepted-Position merge did two things retail never does,
on every single Position packet: it installed the wire placement frame and
unparented unconditionally, and it derived the record's FullCellId from
bare wire acceptance. Both are now correct, and they land together - a
half-flipped intermediate (classified flags with the wire stamp, or vice
versa) is exactly the mixed-residency state this campaign keeps paying for.
WHY the flags need no route. SmartBox::HandleReceivedPosition @0x00453FD0
decides both pre-placement writes BEFORE MoveOrTeleport is consulted: Gate A
@0x0045400C returns @0x0045409D ahead of unset_parent @0x00454129 and ahead
of the HasAnims SetPlacementFrame gate @0x00454137. Neither gate reads the
near/far/teleport classification. So the two flags are a pure function of
(disposition, hasAnimations) and are computable inside the merge, pre-merge,
with no signature change, no route construction and no playerDistance - the
scoping's ~150-400-line route-plumbing estimate over-counted because it did
not see this. That truth table IS
RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition's own
ApplyPlacementFrameBeforeRouting/UnparentBeforeRouting rows; the classifier
stays the oracle and the equality is pinned by test, not by a shared path,
so each computation remains separately sabotage-verifiable.
WHY the cell is withheld. HandleReceivedPosition reads the wire objcell_id
into a LOCAL @0x00453FE3 and hands it only to BlipPlayer / TeleportPlayer /
MoveOrTeleport / ConstrainTo; it never assigns the object's cell. The
object's cell moves inside the placement family (SetPositionInternal
@0x00515BD0 to set_cell, enter_world) or per-frame transit, and nowhere
else. The continuation executor has encoded that rule since the executor
slice; this caller now matches it verbatim.
WHAT DELIBERATELY SURVIVES. Two steady-state wire-cell writers stay,
downstream of the merge and outside the classification window: the
OnPosition prologue rebucket (W2, into CommitRebucket), which is also the
local player's own cell-freshness path, and the post-routing wire-cell adopt
for non-placing arms (W3, AP-135). Gating W2 "for symmetry" would freeze the
player's canonical cell between teleports and #319's child-cell equality
would inherit the freeze. AD-60's rewrite names both so the retirement
cannot be misread as "wire acceptance never changes residency anywhere".
REGISTER. AP-131 RETIRED - the unconditional literals no longer exist; the
caller was corrected, not deleted, so the row's own "deleted at the
production cutover" framing is overtaken. AD-60's legacy half RETIRED and
the row REWRITTEN rather than deleted, naming W2/W3 (route 4b-3's D8
precedent: a silent whole-row deletion would hide surviving channels).
AP-130 amended - the merge consumes the same static HasAnimations proxy,
deliberately not escalated to a live animation-queue read. AP-146 and #320
amended - their "accepted inbound Position (RefreshSnapshot into
RuntimeEntityRecord.cs:234)" local-player cell writer is now the generic
tail's CommitRebucket, and a ForcePosition (which returns before that tail)
is placement-receipt-authoritative. #275 closed.
HEADLINE BEHAVIOURAL DELTA, stated once: a refused or contended local
ForcePosition now leaves FullCellId at the last committed cell where the
merge used to stamp the refused packet's wire cell. Retail cannot refuse
(AD-62) and its body keeps its last placed cell, so the new shape is the
retail-reachable one.
THREE CONSUMER SITES THE CONTRACT'S BLAST-RADIUS SURVEY MISSED, all
D2-caused, all found by the suite rather than by reading, all intended
semantics rather than regressions (recorded in the contract's new section
14):
(1) DatLiveEntityProjectionMaterializer's self-projection branch reads
FullCellId inside OnPosition's prologue recovery, ahead of W2. It now
correctly declines to project from an unplaced wire claim; production
installs the bucket at W2 in the same call (verified: no return between
the recovery call and W2 is conditioned on IsSpatiallyProjected or
FullCellId). Two hydration tests asserted the bucket at the recovery
boundary and now drive the production W2 step - the same shape as trap
T2, one layer up.
(2) ProjectileController.SyncPresentationFromResolvedBody writes
ParentCellId = record.FullCellId. On a refused missile placement that is
now the committed source cell. The MAJOR-1 invariant is unchanged and is
now asserted as the identity it always meant rather than as a wire-cell
constant.
(3) The merge's Rebucketed ternary does NOT become always-Updated as the
contract predicted, and is deliberately kept: the
Forget(restoreCancelledPark: true) above it can roll a wakeable
lost-cell park back, and RestoreParkWithdrawal restores canonical
residency. That is a real cell edge produced inside this method by a
placement owner.
TEST-COUNT RECONCILIATION. Baseline measured at this HEAD by stashing the
change: Runtime.Tests 1176, App.Tests 4135 (4132 passed / 3 skipped),
solution 11,106 passed / 4 skipped - matching the recorded figure at
6921a027 exactly. Post-change: Runtime.Tests 1195, App.Tests 4135 unchanged,
solution 11,125 passed / 4 skipped / 0 failed. Net +19, entirely new Runtime
tests: 3 facts plus a 12-row matrix theory in
InboundPhysicsStateControllerTests, 1 fact plus a 2-row theory in the new
RuntimeSteadyStatePositionMergeTests, and 1 fact in
RuntimeAcceptedPositionDriveControllerTests. No test was deleted; five
existing tests were rewritten in place, never delete-only. No new skip; none
of #302/#308/#321 appeared.
SABOTAGE VERIFICATIONS (each new discriminating test, both directions;
production line broken, suite run, line restored):
installPlacementFrame (!force && !hasAnimations) to (!force)
5 fail: ApplyOnAnimatedEntity_NeverInstallsTheWirePlacementFrame plus
the 4 animated non-force matrix rows.
installPlacementFrame to false
6 fail: ApplyOnNonAnimatedEntity_InstallsTheWirePlacementFrame,
PositionPlacementAbsentAndPresentZeroBothApplyRetailZero plus the 4
non-animated non-force matrix rows.
clearParent (!force) to true
3 fail: ForcePositionOnParentedLocalPlayer_RetainsTheParentAttachment
plus the 2 force+parented matrix rows.
clearParent (!force) to false
4 fail: the 4 Apply+parented matrix rows.
refreshPosition false to acceptedPosition
4 fail: AcceptedPosition_WithholdsTheWireCellAtTheMergeBoundary,
ContendedForcePosition_WritesNoResidencyAnywhere,
ReentrantNewerPositionDuringPickupDiscardSuppressesStalePickupDelta,
MissileFarRefused_...ParentCellIdAgreesWithCommittedCell. Confirmed a
second time by the baseline measurement above, where the withhold test
was the sole red.
CommitRebucket publishes Updated instead of Rebucketed
2 fail: both parent classes of
CellChangingAcceptedPosition_ConservesOneRebucketAndOneChildPropagation.
RuntimeEntityDirectory.SetFullCell drops PropagateFullCellToChildren
2 fail: the same two rows.
T4 respected: the ForcePosition placement-frame half is inert
(appliedPlacement keeps old.PlacementId under either flag value), so the
force row's discriminating assertion is parent retention, never the frame.
NOT DONE, deliberately: the executor is still not wired into the
steady-state path (#275's alternative branch); W2/W3 are untouched; no probe
added or stripped; AP-130's proxy not escalated; no while-here unification
of the two merge callsites. No automated OnPosition-level test drives the
full pickup / drop / reproject sequence (no fixture covers pickup at that
layer); the contract's connected gate recipe item 1 is the positive evidence
for it and has NOT been run - this commit is not connected-gated.
Contract: docs/research/2026-08-05-c5b-contract.md (committed here, with its
section 14 implementation outcome appended).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A DatSoundCacheTests concurrent-decode-dedup fact failed once under full-suite
load during C5a's commit-1 standalone verification and passed clean in
isolation.
Filed as its own issue deliberately. It is neither #302 (PortalProjectionTests
GC-allocation, App.Tests) nor #308 (NakEmissionTests wall-clock, Core.Net.Tests),
and the standing rule that those two must never be conflated exists precisely
because absorbing a new intermittent into an existing "flake class" is how a
real defect gets dismissed as noise.
What is genuinely unknown is whether this is a fixture race or a thread-safety
defect in the decode cache itself. That distinction is load-bearing:
DatCollection is already recorded in project memory as NOT thread-safe, so an
audio decode cache racing under load would be the same family rather than a
coincidence. The first step is a repeat/stress run of Core.Tests alone — load-
only means scheduling pressure, reproduction in isolation means a real race.
Explicitly no retry, Skip, or delay: a masked race is worse than a red test.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Member-wise deletion of the three legacy resolver members named in
docs/research/2026-08-05-c5a-contract.md: PhysicsEngine.Resolve,
PhysicsEngine.HasCellSurface, and PhysicsEngine.ResolvePlacement. An
exhaustive receiver census over src/ found zero production callers of any
of the three — every production placement writer already reaches the
canonical PhysicsEngine.SetPosition transaction exclusively through
RuntimeSetPositionState (three call sites total). The deletion is purely
member-wise: IsSpawnCellReady and AdjustPosition, which shared the same
source region as the deleted members, are preserved byte-identical — every
remaining production caller of either (including PhysicsCameraCollisionProbe,
AdjustPosition's sole surviving production caller) is unaffected.
Companion changes:
- PlayerMovementController's 3-argument SetPosition test overload is renamed
to SeedPlacementForTest (internal) and CommitPreparedPosition is deleted;
83 call sites across 19 test files were mechanically renamed to match.
- Seven pinned test dispositions from the contract are executed:
3.1 (PhysicsEngineTests.cs: 11 legacy-resolver tests deleted, 6
ResolveWithTransition tests kept), 3.2/3.3/3.4 (re-point to canonical
SetPosition, with TransitionScratchDifferentialTests.cs additionally
gaining positive IsCommitted assertions after each bitwise comparison so
the differential proves a placement actually committed, not just that two
possibly-uncommitted results match), 3.5 (Runtime rename), and 3.6
(PlayerMovementPlacementTransactionTests.cs rewritten — its xmldoc now
states plainly that the render-root publish moved to
RuntimeSetPositionState.cs, but the sticky-release relocation claim was
false and is retracted; this disposition's coverage loss is the sticky
release path, not silently absorbed elsewhere).
- Stale `PhysicsEngine.Resolve`/`Resolve` doc citations in CellTransit.cs,
PlayerMovementController.cs, and HeadlessSessionWorldProjection.cs are
corrected to name the surviving canonical entry points by symbol
(SetPosition, AdjustSetPosition/AdjustPosition, ResolveWithTransition)
rather than fragile line numbers.
Retires AP-1 and AD-1 in docs/architecture/retail-divergence-register.md:
both rows described production zero-delta placement routing remaining on
the legacy resolver pending the Slice 4B2/4B route cutover; that resolver
no longer exists, so the condition each row tracked is now structurally
false rather than merely narrowed. AP-145 (routed through the prior commit)
and this commit's AP-1/AD-1 together bring the section counts to 101 AP / 47
AD active rows.
Builds on the AP-145 fix (previous commit) — this commit's staged tree was
independently rebuilt and its four suites independently rerun on top of
that commit before this commit was created, in addition to the combined
rebuild/rerun below.
Full-solution build: 0 errors (21 pre-existing warnings, all unrelated).
Suite results (combined tree): Core 4270/4271 passed (1 skip; the single
DatSoundCacheTests concurrent-decode-dedup failure is a known load-sensitive
race, confirmed passing standalone and unrelated to this change), Runtime
1176/1176, Headless 86/86, App 4132/4135 (3 skips).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
RuntimePlacementPresentationSink.TryPublishPlace previously published the
local player's collision-shadow pose with a direct LocalPlayerShadowState.Set
call — a plain cache write that never touched PhysicsEngine.ShadowObjects.
Because LocalPlayerShadowSynchronizer.SyncPose's own dedup check compares
against that same cache, the direct write could pre-seed the cache with the
destination pose and cause the next real SyncPose call to see "nothing
changed" and skip its own ShadowObjects publish — leaving the real collision
shadow at the pre-teleport position until an unrelated movement tick forced
a real publish.
Fix: TryPublishPlace now calls _localPlayerShadowSync.SyncPose(...,
force: true), the same publisher ordinary per-tick movement uses, so Place
always drives a real ShadowObjects write before the cache updates.
TryPublishWithdrawal carried the exact mirror asymmetry (a bare
LocalPlayerShadowState.Clear with no ShadowObjects.Suspend, leaving a live
phantom shadow row at the park's source cell for the whole park window — the
#184 shape) and is fixed in the same commit, same one-call shape:
_localPlayerShadowSync.Suspend(entity). The sink no longer holds a direct
LocalPlayerShadowState reference; both halves route exclusively through the
one synchronizer, which owns the cache internally.
The single LocalPlayerShadowSynchronizer instance is now constructed in
LivePresentationComposition (before the sink) and threaded through
LivePresentationResult to SessionPlayerComposition, which no longer builds
its own — this guarantees the sink's Place/Withdraw edge and ordinary
per-tick movement publish through the exact same publisher and cache rather
than two independent instances that could drift out of sync with each other.
TryPublishPlace's xmldoc now states the behavioural nuance directly: routing
through SyncPose means Place inherits SyncPose's own admission guard
(IsHidden, cellId == 0, not-current-visible-projection), which the old
direct .Set() call never consulted. Under those conditions SyncPose now
calls Suspend instead of publishing — correct and symmetric, but new
behaviour worth flagging at the call site, not just in a test comment.
RuntimePlacementShadowCompositionTests.cs (#318) proves four facts against
the real ShadowObjects registry, not the cache: a bare Place publishes a
real row at the destination cell with the source cell's row gone; a
subsequent ordinary per-tick Sync is then a correct no-op; a Place for a
registered non-local-player entity leaves its row at the source cell
untouched and never touches the player's cache (route 7 P4 — the fix lives
entirely inside the pre-existing player-only gate); and Withdraw suspends
the real registry row, not just the cache, with the retained
(suspendable) registration surviving for a later restore. All four were
sabotage-verified in both directions.
RuntimeForcePositionRenderCommitTests.cs (B2) drives a real end-to-end
accepted ForcePosition through RuntimeEntityObjectLifetime.TryApplyPosition
and RuntimeAcceptedPositionDriveController.TryExecuteAcceptedLocalPosition
against a live HostFixture, asserting both the committed render position
AND a cell change that deliberately crosses out of the spawn's outdoor grid
cell, so the cell assertion is independently falsifiable rather than riding
along with the position assertion.
Retires AP-145 (this fix) in docs/architecture/retail-divergence-register.md.
AP-1 and AD-1 are untouched by this commit — they retire separately in the
deletion-sweep commit that follows.
Evidence chain: docs/research/2026-08-05-c5a-contract.md (the governing C5a
slice contract), docs/research/2026-08-05-c5a-architecture-review.md (round
1, FAIL — three MAJORs: vacuous route-7 P4 test, unfixed Withdraw-side
mirror asymmetry, non-driving B2 test), docs/research/2026-08-05-c5a-architecture-review-round2.md
(round 2, PASS with two MINORs — an unfalsifiable B2 cell assertion and the
undocumented SyncPose guard nuance, both fixed here).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A player-parented child never received a canonical cell. Its FullCellId stayed
0 for its whole attached lifetime, so it could not follow the player across a
boundary. Scope was wider than the local player: every REMOTE player's
equipment too.
ROOT CAUSE. EquippedChildRenderController hardcoded ParentInstanceSequence: 0
for a parented CreateObject. Correct for creatures and statics, which really
are sequence 0; wrong for players, whose ObjectInstance is Character.TotalLogins
(ACE Player_Networking.cs:37). The relation filed under (playerGuid, 0) while
the record carried TotalLogins, so both route-7 write sites — D1's attach
re-cell and D2's propagation lookup — keyed on an incarnation that never
matched. TryCommitParent did not validate the sequence, so the attach
succeeded and printed normally. Silent.
A ROUTE 7 REGRESSION (cd3129e9) that un-masked a latent bug: the TickChild call
route 7 deleted was keyed on the child guid alone and was structurally immune
to a wrong parent key.
THE FIX IS TO STOP TREATING PLAYERS DIFFERENTLY, not to special-case them.
Retail's attach path is guid-only end to end — PhysicsDesc::get_parent_id
@0x00558a18 -> CObjectMaint::GetObjectA @0x00558a2d -> set_parent @0x00558a3e,
with SetChildren @0x00509370 hash-walking by guid — and neither set_parent
overload (@0x00515A90, @0x00515B50) nor enter_cell @0x00510ED0 contains any
player test or instance-sequence read. Our player/non-player split was purely
an artifact of keying relations by (guid, incarnation) against a wire message
that carries no parent incarnation. Late-binding to whoever currently holds the
guid is retail's own semantics. Fixed at BOTH producers: OnSpawn and
OnCreateParentAccepted, the second carrying the byte-identical defect and not
named in the contract's scope line.
THE INVARIANT IS EQUALITY, NOT FRESHNESS. The contract rejected both framings I
offered: every one of the 45 FullCellId liveness predicates excludes a
committed child on a NON-cell clause first, so the child inherits only the
parent record's existing staleness, which is already present today with no
symptom. The key fix alone restores child-equals-parent for every parent class.
TWO SITES GATED, inert only because the cell was zero and would have woken
wrongly: the hydration candidate loop (a nonzero-cell child would take the
legacy RebucketLiveEntity -> CommitRebucket, a second canonical writer — route
7's exact defect class) and RestoreShadow (would install a broadphase row for
the weapon, the #184 shape, contradicting route 7's P4). Retail anchor:
update_object's parent != 0 early-out @0x00515D40 — children are never
independently re-placed.
THREE MAJORS WERE FIXED BY DELETION. The first pass added a deferral queue for
an unaddressable parent, carrying a missing child-freshness gate (A2), a
sentinel-0 collision with the generation filters (A3), and unbounded
accumulation (A5). Both reviewers then proved the deferred branch unreachable
for BOTH producers — RegisterEntityCore defers the entire CreateObject one
layer above, reading the same ?? chain, and CreateParentUpdate is produced only
inside AcceptCreateCore, after that gate passes. The machinery was deleted
rather than repaired, and the diff SHRANK to 76 added / 13 removed from 91/24
while gaining the A1 fix. Retail confirmed the deletion does not diverge:
acdream's real port of retail's per-guid replay (QueueBlobForObject) is a
different, untouched layer, and the deleted queue was a third redundant one
downstream of it.
THE GUARD MUST NOT TEAR WHAT IT PROTECTS. The first pass threw
InvalidOperationException AFTER the canonical half had committed, so the one
time it fired it left the child parented with no committed relation and a
staged one blocking Resolve — a torn transaction, the exact outcome the
contract pinned against. Now a pure CanCommitIncarnation precondition checked
BEFORE the commit at both sites, with a logged refusal instead of a throw.
Route 3's N3 principle (do not make a transient fatal on a host that must
survive 30 sessions x 2 hours) reinforces it, but the tearing argument stands
alone.
TEST QUALITY, the recurring lesson in its most refined form. The A1 test
initially passed sabotage FOR THE WRONG REASON: a mismatched ChildPositionSequence
meant TryCommitParent's own gate refused in either ordering, so the three
assertions carrying A1's meaning passed both ways and only an incidental
staging assertion failed. It failed on stranding, not tearing. Corrected, the
sabotage now names line 925 — Assert.Null(snapshot.ParentGuid), with the
parent's guid in it — proving the canonical mutation happened before the catch.
"Fails under sabotage" is necessary, not sufficient; WHICH assertion fails is
the real question.
The dual parent-class matrix (player 0x5… incarnation > 1 vs creature 0x8…
incarnation 0, identical outcomes, sabotage-verified in both directions) is the
structural fix for how this survived a full dual review and two connected
sessions: every prior test and both captured gate logs used sequence-0 parents.
Register: AP-142 clause (f); AP-132 amended to distinguish the two producers;
new row AP-146 for the local player's coarse canonical cell (retail writes it
per tick at SetPositionInternal @0x00515330 — which, per the retail review, ALSO
walks this->children writing each child's objcell_id @0x005153AE-@0x005153D8,
so retail's per-tick child propagation lives in the same function). That
divergence had no row at all, a standing rule-1 violation now corrected.
Follow-up #320 filed for making the player's cell track ordinary movement —
deliberately excluded here: it touches the landblock-preserve contract, the
Rebucketed cadence, route-2/4b-3 classification inputs AP-136/AP-138 spent four
review rounds pinning, and the portal-space frozen-source-cell race.
Two dual review rounds; 6 architecture MAJORs and 2 retail MAJORs closed.
Diagnostic refusals are latched per child guid and the latch clears on
Clear()/RemoveChild, so a recycled guid's next incarnation still logs rather
than being silently suppressed.
Complete Release suite MEASURED at 11,112 passed / 4 skipped / 0 failed
(baseline 11,090 at 52175aa1, +22). Neither known flake fired.
STILL OWED: the connected gate, with the CORRECTED positive criterion — assert
the equipped child's FullCellId EQUALS the parent's after a crossing (a zero is
a failure, not a silence), run with BOTH a player and a creature parent, plus
the new step carrying an armed creature across a landblock unload/reload.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The handoff carried gate 4 as unexercised with an UNESTABLISHED trigger after
route 7 invalidated the unwield-to-3D recipe. That was wrong, and the evidence
was already in the captured logs.
c5-gates.log shows the pickup-then-drop test reaching the teleport arm five
times for the exact guids in the [B.5] pickup lines, every one committed — all
labelled cause=teleport-ts. The classifier predicate is a short-circuit OR
(RuntimeAuthoritativePositionRouteClassifier.cs:391): TryApplyPickup zeroes the
item's cell so `cellless` is genuinely true at the drop, but ACE also advances
TELEPORT_TS, the first operand matches, and the probe reports teleport-ts. The
condition occurs, classifies, and commits correctly; only the label is
shadowed.
So the cell-less path has been exercised in both gate sessions all along. Gate
4 is closed rather than owed, and the successor is no longer sent hunting a
trigger that cannot produce the label.
The general rule is worth more than the finding: a probe that reports which
branch matched inside a short-circuit expression cannot distinguish "this
condition did not occur" from "it occurred but another matched first." A
load-bearing cause label must be computed from the conditions independently,
not from the winning branch. Same family as #319's unfalsifiable gate criterion
filed the same day — both are gates that cannot report the state they exist to
report.
Emitting both operands (cause=teleport-ts+cellless) retires the question, but
that is a probe change and belongs with the probe-family work in C5c.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The original criterion — "the session counts ONLY if cause=propagate lines
appear" — cannot fail in the presence of the bug it exists to catch. #319
makes a player-parented child emit no probe line at all, so the defect's
signature is ABSENCE, which that wording reads as "not exercised" rather than
"broken". Two captured gate logs contain #319 and neither flags it; the second
was run specifically to thicken this gate and still missed it.
Replaced with a positive assertion: read the equipped child's FullCellId and
require it to EQUAL the parent's after a crossing — a zero child cell is a
failure, not a silence. Probe-line volume drops to a secondary check. And the
gate must now be run with a PLAYER parent as well as a creature parent, since
#319 exists precisely because every probe-firing parent in both logs was
instance-sequence 0 and the sole player parent was the sole failure.
The rule this generalises, added to the handoff's process findings: a gate
whose failure mode is indistinguishable from a not-run manufactures
confidence. Counting evidence-of-success is not the same as asserting the
property; only the latter can fail.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Found by asking why route 7's connected gate stayed thin (one cause=propagate
across 5-6 equipped landblock crossings) instead of recording the thinness and
moving on.
EquippedChildRenderController.cs:134 hardcodes ParentInstanceSequence: 0 for a
parented CreateObject. Correct for creatures and statics, which are genuinely
sequence 0; wrong for players, whose ObjectInstance is Character.TotalLogins
(ACE Player_Networking.cs:37). The relation files under (playerGuid, 0) while
the record carries TotalLogins, so both route-7 write sites — D1's attach
re-cell and D2's propagation lookup — key on an incarnation that never matches.
TryCommitParent does not validate the sequence, so the attach succeeds and
prints normally.
This is a cd3129e9 (route 7) REGRESSION that un-masked a latent bug: the
TickChild call route 7 deleted was keyed on the child guid alone and was
structurally immune to a wrong parent key. Scope is wider than the local
player — every remote player's equipment is affected. Proven by class: every
probe-firing parent across both captured gate logs is 0x7/0x8 (sequence 0);
the sole 0x5 player parent is the sole failure.
User-visible consequence is NIL and that was verified rather than assumed —
rendering has an explicit fallback and attached children are structurally
excluded from spatial roots, physics worksets, collision retirement, radar and
picking.
THE FINDING THAT OUTRANKS THE DEFECT, and it is a flaw in my own gate design:
route 7's owed gate accepts a session "only if cause=propagate lines appear".
A zero-cell player child emits NO line, so the defect's signature is ABSENCE,
which that criterion reads as "not exercised" rather than "broken". Two
captured gate logs contain the defect and neither flags it. A gate that cannot
fail in the presence of its own target bug is worse than no gate — it
manufactures confidence. This is the same shape as route 3's round-2
regression, which I criticised at length in the closeout while shipping this.
The fix is deliberately NOT attempted here: it has more blast radius than the
bug. The player's canonical cell does not track the player during ordinary
movement, so correcting the key alone yields a stale cell rather than a right
one; and three sites are inert only because the cell is zero and would wake on
a fix (the hydration projectionCellId filter, RestoreShadow's broadphase row,
and the initial-create residence FullCellId refusal).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Verified by symbol at HEAD 52175aa1, behind two exhaustive caller censuses
(legacy placement writers; the probe family).
DELETION INVENTORY: 6 deletable symbol groups (~540 production lines), 2 more
deletable only via #275, and 5 that the plan or handoff names as deletable but
are NOT. Deletable now, all with zero production callers: PhysicsEngine.Resolve
(~360 lines — this IS AD-1's legacy demote/lift body) plus its private
HasCellSurface; PhysicsEngine.ResolvePlacement; PlayerMovementController
.SetPosition (both overloads); CommitPreparedPosition; the BeginAcceptedPlacement
/BeginAuthoredPlacement test wrappers. Hazard recorded: production
IsSpawnCellReady sits physically inside the Resolve block.
THE PLAN'S FRAMING IS WRONG FOR HALF THE ROWS. AP-1 and AD-1 are provably
retirable in the deletion commit — both rows' "production still routes through
the legacy resolver" sentences are already false at HEAD. But AP-131 and
AD-60's legacy half are blocked on #275, which is a BEHAVIOUR CHANGE
(classify-then-merge on every steady-state Position, retail Gate A, wire-cell
withhold across a 45+ site residency-predicate blast radius) — not a deletion.
"C5 deletes the paths and the rows retire" holds for two rows and not the
other two.
SEVEN TEST-ONLY-CALLER CASES, each with a disposition, because this is where a
deletion slice silently removes coverage: Resolve's unit tests (behaviour gone
— delete); Issue133DungeonTeleportPrefixTests (behaviour MOVED — re-point, it
is a named-bug regression pin); InitialPlacementOverlapTests and
TransitionScratchDifferentialTests (verify-then-delete / re-point the
differential arm); ~19 files using PlayerMovementController.SetPosition as
fixture setup (mechanical re-point); CommitPreparedPosition's tests (re-point
at the leash-arm replacement); the Begin* wrappers' ~40 sites (keep as a seam).
Also flagged: the #316-preserving test pins a defect verbatim and inverts by
design once measured.
THE CELL-LESS TRIGGER, ESTABLISHED BY READING rather than deferred to a live
session: pickup-then-drop. TryApplyPickup leaves an ACTIVE cell-0 record, and
ACE's drop sends that guid a bare UpdatePosition (Player_Inventory.cs:1443)
with no client-side delete. What remains unknown is only whether a CreateObject
from NotifyPlayers (Landblock.cs:900) precedes it on the ordered stream, which
depends on when ACE populates the fresh item's known-players set — a two-minute
probe run settles it, with route 5's "the gate cannot exist, record that" as
the honest fallback.
SEQUENCING: run the owed gates FIRST as one cheap user session on the current
binary (route-7 thickening, the cell-less falsification, #316's measurement),
then C5a (deletions + #318 + route-2's B2 parity test + retire AP-1/AD-1), then
C5b (#275 + AP-131/AD-60, own contract and dual review), then C5c (closeout
gates after #280 per the plan's own ordering, the ~1,340-line probe strip, the
ledger). The probe strip goes LAST because the owed gates depend on the probes,
with a REMOTE_LANDING carve-out if #316 stays unmeasured.
Seven false or stale plan claims found and listed, including AP-1/AD-1's own
row texts, ILocalPlayerTeleportPlacement being called deletable when it is a
live post-commit suffix, and the 4b "to delete" list being overtaken — the
pre-op ConstrainTo and duplicate constants are already gone and the remainder
is now canonical post-collapse.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Records the five routes plus the OnPosition collapse with their SHAs, the
11,027 -> 11,090 suite trajectory, and the gate status including the two
honest gaps (route 7's single cause=propagate sample; 4b-3's cause=cellless
still unrun with an UNESTABLISHED trigger after route 7 invalidated its
recipe).
Points successors at the closeout handoff before any C5 or placement work,
and names the two costliest process findings inline so they are visible
without opening it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An earlier revision of the gate-results section recorded route 3's autorun
cancel as live-unverified, because the first three portals all reported
autorun=unchanged. That was true of those three and wrong as a conclusion: the
user portalled again with autorun engaged and the fourth line reads
[local-tp] cause=portal host=graphical status=Committed gen=5 seq=4
dest=0x00070145 resolved=0x00070145 hookTail=ran leash=armed
autorun=cancelled
This verifies the PlayerTeleported @0x006B32B0 SetAutoRun(0,1) +
SendMovementEvent port in live play. It was a real gap before this slice —
nothing cancelled the J5.4 autorun latch on arrival, so auto-running into a
portal left you running on the far side where retail stops you.
All four load-bearing fields on that line read correctly: Committed,
hookTail=ran (inversion B — the local teleport_hook runs AFTER placement,
opposite to 4b-3's remote arm), leash=armed (inversion A — the leash IS armed
here, opposite to route 2's ForcePosition rule), and autorun=cancelled.
Route 3's connected gate is now fully exercised. Route 7's remains thin (one
cause=propagate) and gate 4 remains unrun with an UNESTABLISHED trigger.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
User-run against the exact e0f96a55 Release binary with the retail UI and both
probes; user verdict "works great".
Route 3 passes unambiguously: three [local-tp] lines, all Committed,
hookTail=ran, leash=armed, across three destinations. leash=armed is the
load-bearing observation — before the round-2 R3 fix that field read
IsFullyConstrained() and could only ever print unarmed, so the line proves
both the leash arm and the corrected probe.
Route 7 passes but THINLY: 17 [child-cell] lines, of which 13 attach, 3
delete, and exactly ONE propagate. The stated criterion (at least one
propagate) is met, so the gate passes — but propagation across a parent cell
crossing is the slice's whole purpose, and one sample shows the path executes
rather than that it holds across repeated crossings. Recorded as thin rather
than counted as full coverage; a future session should expect double-digit
propagate counts from several equipped boundary crossings.
Route 6 passes on visual confirmation only, which is inherent to a route with
zero production lines and therefore no probe.
Gate 4 (4b-3's cause=cellless) remains unrun, as expected — route 7
invalidated its recorded trigger and the replacement is UNESTABLISHED.
Two things this session did NOT exercise, recorded rather than glossed: route
3's autorun cancel never fired (all three portals report autorun=unchanged, so
the PlayerTeleported @0x006B32B0 SetAutoRun port is live-unverified — engage
autorun before a portal to close it), and AP-144's autonomy divergence stays
structurally unreachable. Per both round-2 reviewers' condition, this session
is explicitly NOT scored as covering #318.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
C4's route work is complete. Records the landings, corrects the campaign
plan's now-false claims, updates the roadmap, and writes the successor
handoff at docs/research/2026-08-05-c4-closeout-handoff.md.
Routes, with review rounds and gate status:
4a 44830a0e | 4b-1 2e8e09ac
4b-3 6dc7ba51 2 rounds; gate PASSED-partial (21cd6e9b), cellless unexercised
5 36255af0 3 rounds, 8 MAJORs; NO live gate possible by design
6 1b484937 zero production lines; its tests found #314
7 cd3129e9 2 rounds + a required third pass, 5 MAJORs
3 e0f96a55 3 rounds; found a 100%-dead production path
plus edc911b0 (the OnPosition dual-tail collapse), aaf0811f (#315),
daef7c98 (#314), a89bcb39 (#316 filed).
Suite 11,027 -> 11,090 passed / 4 skipped / 0 failed. Every checkpoint
0-failed; no test weakened, no Skip introduced.
FOUR CONNECTED GATES ARE OWED and none has been run. Each is recorded with
its recipe and a probe-gated pass criterion, because a clean-looking session
is not a pass: route 6 drops; route 7 equip/carry with
ACDREAM_PROBE_CHILD_CELL=1 (counts only if cause=propagate appears); route 3
portal/recall with ACDREAM_PROBE_LOCAL_TELEPORT=1 (counts only if [local-tp]
appears, and is explicitly NOT scored as covering #318); and 4b-3's
cause=cellless case — whose recorded recipe route 7 INVALIDATED, since
unwield-to-3D no longer yields a cell-less pre-merge cell. Its replacement
trigger is stated as UNESTABLISHED rather than guessed.
Campaign-plan corrections beyond the C4 section, all found by checking
against HEAD rather than trusting the text:
- "six fixture failures ... classify before C5" — resolved as #281, and
"six" was a mis-measurement; the measured baseline was 43.
- "fold in #276 and #277" — #276 only partially (projectile half); #277 not
at all, its trigger never fired.
- "#269 slope-glide visual check" — #269 was closed 2026-07-31, BEFORE the
plan was written. The surviving item is #278(b).
- the 4b-2 bullet's "Still outstanding: #309" — re-scoped 2026-08-04;
only the GotoLostCell half survives.
Seven process findings, each cited to a commit so a successor can check them:
(a) THE CONTRACT CAUSES THE DEFECT — three defects this campaign came from
a contract asserting a mechanism that did not exist; route 3's "Place
re-fires" assumption released the player at the pre-teleport position.
Route 7 adds the variant: enter_cell's part_array guard was correctly
called load-bearing by the research, dropped by the contract, and
inherited as an omission by the code — a right finding that evaporated
across two handoffs with nobody re-reading the source.
(b) INFERRING A FACT YOU CAN OBSERVE IS HOW A FIX GOES SILENT — route 3's
round-2 fix inferred "committed" from a global PendingCount that three
non-committing paths also clear, so the same bug completed cleanly and
PASSED its invariant. Strictly worse than the defect it replaced.
(c) PLANNING DOCS GO STALE ACROSS CUTOVERS — at least five were wrong
against HEAD. Re-verify by symbol, never by line number; route 3's
by-symbol sweep proved only 2 of 5 flagged files actually intersected.
(d) A SKIPPED TEST IS A PERMANENT FALSE SIGNAL — refusing 7 skips uncovered
a production bug that had made the entire portal arm dead code.
(e) SABOTAGE-VERIFY, AND WATCH FOR TESTS READING A CONSTANT THEY PERTURB —
one built a 64,000-node chain and stack-overflowed the host; another
survived deleting the whole behaviour it claimed to pin, because its
assertion read a field written unconditionally one line earlier.
(f) REVIEWERS RETRACT, AND THAT IS THE PROCESS WORKING — three self-
retractions, two of which prevented shipping a wrong register row or a
relocated defect.
(g) A GATE MUST BE ABLE TO SEE THE DEFECT IT GATES — three gates were
unpassable or blind as specified and were corrected BEFORE being run.
Issues: #313, #316, #317, #318 open; #314, #315 closed. Register rows AP-141
through AP-145 added; AD-42 deleted, AD-2 amended.
C5 inherits #318's composition test (discriminating assertion:
PhysicsEngine.ShadowObjects must hold a row at the destination, not merely
the dedup cache), AP-145's cache-without-publish asymmetry, the #276/#277
remainders, and the probe-family strip.
Three things the closeout could NOT verify are stated as such rather than
smoothed: route 3 has no standalone round-3 review document (acceptance lives
in e0f96a55's message and both round-2 pass conditions); route 7's round
terminology differs across its own artifacts; and route 6's lack of dual
reviews is inferred from absent review docs, not stated anywhere.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Removes a duplicate placement authority for local-player portal arrival.
Portalling worked before this change and works after it — this is not a
bug fix, EXCEPT that it found and fixed one dead-code production bug.
THE PRODUCTION BUG: TryExecuteCanonicalPortalPlacement re-read the
accepted destination at Place time, but TryBeginPortalReveal already
consumes that slot at Aim time — so the arm was 100% dead code and every
real portal Place refused with host-token-unavailable. Found only
because we refused to accept 7 skipped tests instead of chasing the
count to zero.
RETAIL IS THE GENERIC PATH FOR THE THIRD ROUTE RUNNING:
SmartBox::TeleportPlayer @0x00453910 = SetPositionSimple(dest, 1) with
flags 0x1012, followed by PlayerPositionUpdated.
BOTH INVERSIONS, WITH THEIR ANCHORS: unlike route 2, the leash IS armed
here (ConstrainTo @0x0045418A) and velocity is zeroed
(set_velocity @0x004541B4); unlike route 4b-3, the local teleport_hook
runs AFTER placement (@0x004538AE).
THE THREE-ROUND DEFECT CHAIN, HONESTLY:
- Round 1 released the player at the pre-teleport position while the
anim stream marched on — the contract wrongly assumed Place re-fires
(process rule 1's third occurrence this campaign).
- Round 2's fix inferred commit from a global PendingCount, which three
non-committing paths also clear — making the SAME bug complete
cleanly and silently. Strictly worse than round 1: round 1 at least
tripped portal-complete-before-materialized.
- Round 3 latches the commit where it actually happens
(ReconcileAndAcknowledgePortal), keyed on reveal generation and
teleport sequence, via TryConsumePortalCommit. Two of the three
required regression tests landed and are sabotage-verified on both
hosts (ParkedPlace_ForgottenByOrdinaryMergeDoesNotLatchAsCommitted /
HeadlessPortalPrepareDestinationForgottenByOrdinaryMergeDoesNotLatchAsCommitted).
The third (force-arm-takes-the-slot) was judged unnecessary on review:
with the inference gone, PendingCount is only a "don't ask yet" guard
at both gates, so a force operation occupying or vacating the slot no
longer changes an input the commit decision reads — the case collapses
into what the landed test already discriminates.
THE B2/P3 RESOLUTION: both round-2 reviews were right about different
branches of the same synchronous call. RuntimePlacementProjectionSubscription
.OnPlacement acknowledges the FIFO head only when TryApply returns true;
a Place whose portal authority went stale (transit ended/superseded
while parked) used to return false, wedging every later entity's
placement receipt behind it forever. Both sinks
(RuntimePlacementPresentationSink, HeadlessRuntimePlacementProjectionSink)
now acknowledge-and-ignore a stale-authority Place instead of refusing
it. The regression test (RuntimePlacementPresentationSinkTests
.PortalPlace_StaleTransitHostOrSequenceIsAcknowledgedAndIgnored) had
been asserting the old, wrong `false` behaviour; it now asserts and
sabotage-verifies the fix.
Also lands: AP-144 (register discipline — the portal movement-event
send reuses the stricter UsePositionFromServer gate where retail's
SendMovementEvent is the looser autonomy_level != 0 test, diverging
only at level 1, currently unreachable), AP-145 + issue #318 (the
local-player collision-shadow presentation write bypasses its own
publisher's ShadowObjects write via a direct cache .Set(), self-healing
only once dedup diverges — filed, not fixed, pending a composition
test), AD-42 deleted (its last citation retired by the canonical portal
arm), AD-2 updated (the wait-cue's trigger predicate now covers a
second cause), and two documentation corrections: the enter_world
misattribution (both call sites are in SmartBox::HandleCreateObject,
only one in the player branch — portal arrival is TeleportPlayer, not
enter_world) and the stale "local player never reaches this path"
comment on the generic-remote-render-pose write.
Suite: 11,090 passed / 4 skipped / 0 failed. No new skips, nothing
weakened.
STILL OWED: the connected two-client gate, with
ACDREAM_PROBE_LOCAL_TELEPORT=1, scored only if [local-tp] lines
actually appear in the capture — and explicitly NOT scored as covering
issue #318 (no composition test yet asserts PhysicsEngine.ShadowObjects
directly).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Retail re-cells children when their parent crosses a cell, recursively, to
unbounded depth. acdream did it from a RENDER tick, so headless parented
children were cell-less forever and the canonical cell had two writers. This
slice makes Runtime the sole authority and demotes App's tick to
presentation-only. Contract:
docs/research/2026-08-04-c4-route-7-contract.md; the research that unblocked
it is docs/research/2026-08-04-retail-parent-cell-propagation.md (ca96ea5e).
Retail: SetPositionInternal @0x00515330 branches on `this->cell == curr_cell`
@0x0051536d; the changed branch reaches change_cell @0x00513390, whose
delegates leave_cell @0x00510f50 and enter_cell @0x00510ed0 self-recurse over
children and write the FULL identity (add_object @0x00510ee2, objcell_id
@0x00510f1e, part-array cell id @0x00510f2b, cell pointer @0x00510f35).
change_cell itself has no child loop.
THE TRAP, recorded because it nearly shipped: the depth-1 loop
@0x0051539c-0x005153d8 is the SAME-CELL fast path (objcell_id and part-array
id only, deliberately not the cell pointer), NOT the propagation. An
implementer who finds it first concludes "depth-1, id-only" and strands every
equipped item at a landblock boundary — the #184 class. The clincher against
that reading: update_object @0x00515d10 early-returns on `parent != 0`
@0x00515d40, so a child never runs its own physics tick and parent
propagation is the ONLY mechanism maintaining its cell.
Route 7 performs NO placement (DoPickupEvent @0x00452240 = unset_parent +
leave_world; DoParentEvent @0x00452290 = set_parent + SetPlacementFrame), so
it arms ConstrainTo nowhere — the leash rule INVERTS relative to routes
2/4/5, and both reviewers confirmed nothing arms.
Propagation is an ITERATIVE WORKLIST, not recursion. The first implementation
recursed with a depth-64 cap; both reviews independently found the cap left a
truncated tail at a stale NON-ZERO cell — permanently unrecoverable, logged
only under a probe flag, and on the withdraw path exactly the #184 shape
AP-142 clause (a) exists to reject. Shipping a fresh #184 instance inside the
slice that fixes stranded children was not acceptable, so the cap was removed
rather than tuned. The worklist retires the cap, the constant, its register
clause, and the failure mode together. Termination: every record on the stack
is already at the target pair, so nothing can be pushed twice and a hostile
A->B->A cycle collapses without a visited set.
The child write deliberately bypasses the public RuntimeEntityDirectory
.SetFullCell and calls the record method directly. This is LOAD-BEARING:
the public method re-enters PropagateFullCellToChildren, which opens with
_propagationWorklist.Clear() — routing children through it mid-drain would
wipe the shared stack and silently drop every unprocessed sibling. Any future
side effect added to the public SetFullCell must be mirrored by hand at that
call site.
Deliberate divergence, recorded not disguised: retail's removal path leaves
children with a null cell pointer but a STALE nonzero objcell_id @0x005133c1.
acdream does not reproduce it, because FullCellId != 0 is the liveness
predicate at 45+ sites — faithful porting would mark dead children live.
AP-142 records this; clause (d) records that acdream cannot gate propagation
on HasPartArray the way enter_cell gates on part_array @0x00510ed8, because
the flag's only writers are graphical and headless never sets it — the reason
is Slice J LAYERING, not a semantic difference (retail's part_array is itself
a mesh-construction product, single assignment site makeAnimObject
@0x0050e930 -> CPartArray::CreateSetup @0x0050e93e).
D7 adopts retail's unset_parent-before-leave_world order @0x0045227f ->
@0x00452286, applied to BOTH pickup paths including the dormant executor
replay. Its inertness was verified by reverting it and finding all 12
propagation tests still green — reported honestly rather than papered over
with a manufactured test, and independently confirmed by both reviewers.
ClassifyLeaveWorld and its request/cause types are DELETED: retail has no
classification here, and method-per-cause IS the retail dispatch shape.
Wiring it would have forced a vacuous teleport-sequence predicate with the
#307 shape.
Two review rounds plus a coordinator-required third pass; 5 MAJORs. One was a
handoff failure worth recording: enter_cell's part_array guard was correctly
identified as load-bearing by the research, dropped by the contract when it
enumerated the writes, and inherited as an omission by the code — a right
finding that evaporated across two handoffs with nobody re-reading the source.
Another was a test that survived deleting the entire behaviour it claimed to
pin, because its assertion read a field written unconditionally one line
earlier.
NoProjection is structurally unreachable from TickChild (TryResolveExactAttachment
performs a strictly stronger form of the same guard one call earlier). Kept as
a fail-safe, unit-tested directly, and documented in two places rather than
wrapped in a fabricated end-to-end test.
Headless regression test — the direct gate for this defect, which FAILED
before this work because no code path existed:
RuntimeLiveEntitySessionControllerTests
.DirectSink_D5_StandaloneParentEventCommitsChildToParentsExactCell.
Probe: ACDREAM_PROBE_CHILD_CELL=1 emits [child-cell] lines at all four write
sites (attach / headless-attach / propagate / withdraw / delete). TEMPORARY.
Complete Release suite MEASURED at 11,079 passed / 4 skipped / 0 failed
(baseline 11,063 at cff52c44, +16). An allocation flake appeared once under
load and was proven NOT this slice by reachability — RuntimeCollisionReportingState
contains zero SetFullCell and zero ParentAttachments references.
STILL OWED: the two-client connected gate (equip/unequip, carry across
landblock boundaries, pickup, loot, reconnect) with ACDREAM_PROBE_CHILD_CELL=1,
and a session counts only if [child-cell] cause=propagate lines appear.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The last C4 route. Portalling works today; route 3 removes a duplicate
placement authority (LocalPlayerTeleportPlacement.Place plus headless's
ResynchronizeLocalPlayerForPortalArrival), it does not fix a bug.
~225-400 added non-comment production lines, one slice. Every site
re-verified at HEAD by reading, not inherited from the scoping.
The authority's shape is RIGHT as-is and redesign is forbidden:
RuntimePortalPlacementAuthority's 4-tuple is exactly what transit owns per
reveal, and IsValid already cross-checks generation. Only the PRODUCER is
missing — the campaign plan's "the adapter does not exist" overstates the gap,
since consumption and validation are live production code at three layers.
The producer additionally needs no new WorldRevealCoordinator exposure: it
re-derives the host token through transit's idempotent
TryRegisterHostProjection, which makes a superseded token unobtainable by
construction.
Retail's local portal arrival is the GENERIC path for the third route running:
SmartBox::TeleportPlayer @0x00453910 is SetPositionSimple(player, dest, 1)
with flags 0x1012 — route 2's exact primitive — plus PlayerPositionUpdated.
TWO rule inversions are the contract's loudest section, because an implementer
arriving from the routes just landed will otherwise carry the wrong rule:
route 2's "never re-arm the leash" INVERTS (the teleport branch arms
ConstrainTo @0x0045418A and zeroes velocity @0x004541B4), and 4b-3's
hook-before-placement ordering INVERTS (the local teleport_hook runs AFTER
placement, from PlayerPositionUpdated @0x004538AE).
Three findings new since scoping: PlayerTeleported @0x006B32B0 byte-confirmed
as SetAutoRun(0,1) + SendMovementEvent, with the autorun-cancel gap verified
real — nothing cancels the J5.4 latch on arrival today; TryPublishPlace writes
no pose, so the committed-receipt suffix is the render entity's mover; and
headless TryCompletePortal's fully-synchronous suffix creates a
receipt-past-EndTeleport FIFO-wedge hazard, covered by proof obligation P3.
SEQUENCING BLOCKER recorded in the contract's front matter: route 7's
concurrent diff modifies five route-3 surfaces. The collision is textual, not
semantic — route 7 adds parent-cell machinery and touches neither the portal
transit, the drive controller, nor either duplicate authority — but route 3
must not start until route 7 commits, and must then re-verify its inventory by
symbol and re-measure the Release baseline.
#280 is SPLIT OUT, siding with the campaign plan's own separate sequencing
over the session handoff's "rides with route 3" claim.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two read-only research landings that unblock the last two C4 routes.
**Parent-cell propagation (unblocks route 7).** Retail DOES re-cell children
when the parent crosses a cell, recursively to unbounded depth.
SetPositionInternal @0x00515330 branches on `this->cell == curr_cell`
@0x0051536d; the changed branch reaches change_cell @0x00513390, whose
delegates leave_cell @0x00510f50 and enter_cell @0x00510ed0 self-recurse over
children and write the FULL identity (add_object @0x00510ee2, objcell_id
@0x00510f1e, part-array cell id @0x00510f2b, cell pointer @0x00510f35).
change_cell itself has no child loop — the recursion is in the delegates.
The clincher: update_object @0x00515d10 early-returns on `parent != 0`
@0x00515d40, so a child never runs its own physics tick and parent
propagation is the ONLY mechanism maintaining its cell.
The trap this retires: the depth-1 loop @0x0051539c-0x005153d8 is the
SAME-CELL fast path (objcell_id and part-array id only, deliberately not the
cell pointer), not the propagation. An implementer finding it first would
conclude "depth-1, id-only" and ship equipped items stranded at landblock
boundaries — the #184 class. Route 7's planned set_parent-only write would
have done exactly that.
Settled by READING, not by a debugger trace. The scoping had listed this as
needing live cdb evidence, but change_cell/set_cell's child handling had
simply never been read; the project's grep -> decompile -> verify order had
not been exhausted. One BN field-name gap was closed by walking
struct CPhysicsObj in the verbatim acclient.h, so no PE byte-decode was
needed either. A breakpoint set is recorded for optional confirmation only.
**Route 3 scoping (portal, the last route).** ~225-400 added non-comment
production lines, ONE slice, contingent on #280 splitting out. Portalling
works today; route 3 removes a duplicate authority
(LocalPlayerTeleportPlacement.Place), it does not fix a bug.
Eight dated-inventory claims are now false, the most consequential being "the
binding machinery is 100% dormant end to end" — the portal authority's
CONSUMPTION and validation side is live production code at three layers and
is exercised by every placement; only the PRODUCER adapter is missing. That
makes route 3 materially smaller than the campaign plan implies.
#280 SPLITS from route 3, definitively: it is a reveal-gate/prefetch-window
concern (WorldRevealReadinessBarrier's neighbourhood radius versus retail's
mid_radius, LScape::PreFetchCells @0x00505660 / SmartBox::SetRegion
@0x00453227), mechanically disjoint from the placement cutover — route 3
reads the ready predicate, #280 rewrites it. The campaign plan already
sequences #280 separately; only the session handoff said it "rides with"
route 3, and the plan is right.
Retail's local portal arrival is the GENERIC path for the third route running:
SmartBox::TeleportPlayer @0x00453910 is SetPositionSimple(player, dest, 1)
with flags 0x1012 — route 2's exact primitive — plus PlayerPositionUpdated.
Two rule inversions recorded so route 3's implementer cannot carry the wrong
rule forward from the routes just landed: route 2's "never re-arm the leash"
INVERTS here (the teleport branch arms ConstrainTo @0x0045418A and zeroes
velocity @0x004541B4), and 4b-3's hook-before-placement ordering INVERTS (the
local teleport_hook runs AFTER placement, from PlayerPositionUpdated
@0x004538AE). The classifier's dormant LocalPlayer-teleport route already
encodes both.
Two documentation defects found in passing and recorded, not fixed: the
2026-07-16 portal pseudocode attributes portal arrival to enter_world (that
is the login path), and a stale comment hides a live second writer — the
generic wire-pose write does run for the local player (AP-131/C5 scope).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
LiveEntityNetworkUpdateController.cs's TryCommitAuthoritativeVelocity
call site carried a comment claiming "MoveOrTeleport installs that
exact vector with set_velocity". The C4 route 5 byte-decode of
CPhysicsObj::MoveOrTeleport @0x00516330-0x00516438 (the immediately
preceding commit) shows this is false: the function never reads its
velocity argument's stack slot in any branch, and UnpackPositionEvent
performs no set_velocity either. The only set_velocity in the whole
accepted-Position chain zeroes the LOCAL player @0x004541B4, a
different call site entirely.
This commit only corrects the comment. The 4a call itself is left
unchanged in production, deliberately out of C4 route 5's scope — the
route governs RuntimeSetPositionOperationKind placement dispatch, not
the pre-existing remote velocity commit. #317 (docs/ISSUES.md, filed
in the previous commit) tracks the follow-up: audit the whole
accepted-Position velocity chain and either find the correct retail
source or remove the call with a divergence-register row.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ports retail's missile Position handling into the canonical Runtime
placement owner instead of the deleted ApplyAuthoritativePosition
short-circuit. The Create/residence-window halves of the projectile
pipeline (RuntimeProjectile binding, TryBind's adopted-body branch,
the collision/shadow registration) were already canonical from prior
slices; this closes the remaining gap — how an ACCEPTED Position for
an in-flight missile is classified, placed, and presented.
Byte-decode (Step 1 hard gate, before any code was written):
CPhysicsObj::MoveOrTeleport @0x00516330-0x00516438 disassembled from
the PDB-paired binary (Capstone, x86 32-bit thiscall). `ret 0x10`
establishes four stack args; [esp+0x7c] (arg5, the velocity pointer)
is never referenced in any of the three branches (teleport/near/far).
The retail reviewer independently reproduced this by searching the
whole function body for the `24 7c` mod/rm+disp8 encoding a
`[esp+0x7c]` read would require and found zero occurrences. This
retired a fabricated `?? Vector3.Zero` fallback in the deleted method
— retail's PositionPack::UnPack initializes an absent velocity to
zero and MoveOrTeleport never installs it; the projectile's Vector
channel (RuntimeProjectilePhysicsUpdater.ApplyAuthoritativeVector)
remains the sole velocity authority for a missile. D-P5 in the
contract; the Runtime seam commits no velocity from the Position
packet at all.
The unbound-missile fix: RuntimeEntityObjectLifetime's
ClassifyRemoteAcceptedPosition now derives ProjectileAuthoritative
from a CONJUNCTIVE predicate — the Missile bit AND a bound
RuntimeProjectile whose Body is the canonical PhysicsBody — never the
bit alone. Retail places every non-player CPhysicsObj unconditionally
(there is no missile-specific placement gate in MoveOrTeleport or its
callers), so an unbindable or not-yet-bound missile taking the
ordinary remote tail is retail-faithful, not a fallback: the earlier
bit-only discriminator would have silently frozen it instead.
AP-141 records this as a deliberate, recorded divergence, not
fidelity. Retail mechanically WOULD arm a missile's ConstrainTo leash
on any nonzero MoveOrTeleport return: HandleReceivedPosition
@0x00453FD0's only kind test is player-vs-not, ConstrainTo
@0x00454272 has no kind test of its own, and CPhysicsObj::ConstrainTo
@0x00510520 creates a PositionManager on demand via
MakePositionManager @0x00510523 if one doesn't exist. acdream
deliberately does not construct that EntityPhysicsHost/
PositionManager/InterpolationManager chain for a ballistic body — the
route-5b split the C4 route 5 contract rejected — so a live missile
never shows an armed leash and never catches up via the near/
UnroutedCatchUp policy. This divergence is safe specifically because
ACE never sends UpdatePosition for a missile
(references/ACE/Source/ACE.Server/WorldObjects/WorldObject_Tick.cs:
333-334, SendUpdatePosition() commented out inside the
PhysicsState.Missile branch at :265) — every half of this row is
deterministic-test-gated only, never exercised against a real server.
AP-141 also records the surviving ConstrainTo re-anchor divergence
under clause (b): for the adopted-body case (TryBind's shared-body
branch — an ordinary remote whose Missile bit is set by a later
State packet, so it still carries a live RemoteMotion), acdream now
ports retail's teleport-branch and far-branch StopInterpolating
action (Interp.Clear()), but never re-arms or re-anchors the
inherited ConstrainTo leash the way retail's HandleReceivedPosition
@0x00454254/@0x00454272 does on every nonzero return. The risk
column's earlier wording — that a stale leash "would drag the body
toward a stale anchor" — was wrong and is retracted in this same
commit: ConstraintManager.ConstraintPos is write-only in both retail
and the port (never read by AdjustOffset), and
ConstraintManager::adjust_offset @0x00556180 only tapers or zeroes an
already-composed per-tick offset while InContact — a leash brakes
motion the interp/sticky chain already produced, it cannot pull
anything toward the anchor. The real residual is one tick of un-reset
brake accumulator, contact-gated, and it cannot move an airborne
far-snapped missile at all (the clamp branch does not run while
airborne).
NO CONNECTED GATE EXISTS for this route, by design: ACE never sends a
missile UpdatePosition (see above), so retail's own server never
exercises this code path in play. Every proof obligation here is
test-gated only — Runtime and App-level fixtures constructing the
packet directly — never a live client/server capture.
Three review rounds closed 8 MAJOR findings before this landed:
round 1 (A1 App discarded the seam's status; A2/R1 silent swallow on
an unbound missile; A3/R2 the adopted-body teleport_hook never
wired; A4/A5 zero Runtime/App test coverage); round 2 (a
ParentCellId regression introduced by round 1's own R6 finding,
which the retail reviewer retracted the following round as factually
wrong — the fix here is the REVERT to record.FullCellId, not the
relocation round 1 shipped; B2 the far-branch StopInterpolating skip
never extended to the adopted-body case; residual App/Runtime store-
path coverage; a per-packet closure contradicting the file's own
#315 cached-delegate pattern). Round 3 closed on coverage alone (no
defect): the Advance() retry arm's projectile branch — added at
round 2, semantically reordered at round 2's B5 fix (skip prediction
invalidation on a re-parked Contention, since it writes nothing) —
had never been executed by any test; two new tests drive it directly
and are sabotage-verified against both the reordering and the
retry-arm's own SyncProjectilePresentation call site. The one
recorded defect this campaign produced (the ParentCellId regression)
was caused by complying with a review finding that its own author
later retracted — the standing lesson recorded for future rounds is
that review findings are evidence to re-verify against the code, not
commands to obey unconditionally.
Complete Release suite: 11,063 passed / 4 skipped / 0 failed
(baseline 11,036 at 30d3d114, +27 new tests across this campaign).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The entry cited `ddb38f37`, which does not exist on this branch. My
instruction to the implementer asked a commit to record its own SHA — a git
impossibility, since a commit hash covers its own content. The implementer
committed, then amended the SHA into the text, and the amend necessarily
produced a different final hash; it flagged the contradiction rather than
leaving it to be discovered.
Corrected to the real commit and recorded here as its own docs commit, which
is the pattern #314 already uses (fix commit, then a separate closing note
citing it). A wrong SHA in ISSUES.md is worse than no SHA: it is a citation a
later session trusts and cannot resolve.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The OnPosition collapse (previous commit) converged
RunRemoteArmTail's three duplicated call sites into one, which is what
makes caching worthwhile: one cached pair of delegates now serves every
remote guid instead of a fresh closure allocated on every accepted remote
Position (5-10 Hz per remote), regardless of whether the packet was a
teleport.
RunRemoteArmTail's signature changes from a caller-constructed
`Func<bool> isCurrentPositionOwner` closure to two plain value parameters
(`ulong positionAuthorityVersion`, `WorldEntity? expectedEntity`). It stamps
five per-packet scratch fields (`_remoteArmCanonical`, `_remoteArmMotion`,
`_remoteArmPositionRecord`, `_remoteArmPositionAuthorityVersion`,
`_remoteArmExpectedEntity`) from its own parameters, then passes the two
CACHED delegates into ApplyRemoteContactRouting. Observably identical: the
currency check reads the exact same
positionRecord/positionAuthorityVersion/expectedEntity triple either way.
Deviation from a bare cached-Func<bool>-field design, and why:
UpdateFrameOrchestratorTests.ProductionFrameAdaptersRetainTypedOwnersWithoutWindowCallbacks
asserts every typed production owner (LiveEntityNetworkUpdateController
included) carries zero Delegate-typed fields — the GameWindow decomposition
campaign's guard against a callback silently smuggling a window reference
back onto one of these owners. Neither cached delegate here touches a
window (both are bound to this controller alone), but the rule is written
as a blanket field-type check, not a window-specific one. The two
delegates are wrapped in a small nested RemoteArmCallbacks type instead of
being bare fields, which satisfies the guard and keeps the cache a single
named, auditable unit rather than working around the test.
RunRemoteTeleportHook's own allocation (the six-action
RemoteTeleportHookActions bundle) is unaffected — it stays teleport-path-only,
already judged acceptable to defer by the C4 route 4b-3 round-2
architecture review's B4 finding.
dotnet build AcDream.slnx -c Release: 0 errors. Focused suites green at
this commit: AcDream.App.Tests 4104/4107 (3 pre-existing skips),
AcDream.Runtime.Tests 1125/1125.
Closes#315.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
C4 route 4b-3 collapse (docs/research/2026-08-04-onposition-collapse-contract.md).
Behaviour-preserving: the ~640-line duplicated player-guid and NPC-guid
copies of the remote routing tail in LiveEntityNetworkUpdateController.OnPosition
become one guid-blind tail, reached by every remote guid through the single
ApplyRemoteContactRouting/RunRemoteArmTail seam.
Two guid-conditionals survive, both named and justified:
- Row 8 (TS-44 sticky suppression, creature-only): retail's sticky is
independent of this acdream-only steady-state gate; the register row
already describes it as NPC-only and this collapse does not widen it.
- The AirborneSnap arm's interp-clear + shadow-publish (rows 2a/2b,
player-only preserve): unifying either way would be an unauthorized
behaviour change. #316 (shadow publish) is a real, unmeasured
pre-existing defect, deliberately preserved not fixed. The interp-clear's
equivalence could not be proven for the steep-non-walkable-landing edge
case (AdjustOffset's CONTACT-keyed gate vs. AP-139's WALKABLE-keyed
per-tick clear) — preserved per contract stop condition 2 rather than
shipped on an incomplete proof.
Category-(c) resolutions (contract §2.1-2.5), each with its evidence:
- Row 2a (interp clear): PRESERVED — AdjustOffset's `if (!inContact) return`
proves inertness on flat landings, but not on the steep-contact edge case.
- Row 2b (shadow publish / #316): PRESERVED — no design note ever sanctioned
the player-guid skip; the file's own #184 Slice 2b comments contradict it.
- Row 2c (EnsureRemoteMotionBindings): UNIFIED — the method is idempotent
(`if (rm.Host is not null) return rm.Sink;`), so "always ensure" is safe.
- Row 3 (wire-cell adopt ordering): UNIFIED — RebucketLiveEntity already
commits the wire cell before either guid branch runs, so the deleted
player-guid pre-write was a proven no-op.
- Row 4 (LastServerPos/Time sample timing): UNIFIED — on a genuine first UP,
InterpolationManager.Enqueue's already-close branch and the Snapped branch
both converge on the same body pose/orientation for a zero-distance target.
- Row 12 (wall-clock capture): UNIFIED — one shared `nowSec`, a
microsecond-scale skew in acdream-only bookkeeping/diagnostics.
Sabotage check (contract §5, performed and reverted, not committed):
deleting the one remaining TryArmConstraintAfterOperation call failed
10/16 dual-guid matrix tests, spanning BOTH guid halves of every
arming-dependent scenario (teleport, landing, near, far, sticky) — proof
the matrix discriminates a defect regardless of which guid range exercises
it, closing the class of bug that let 4b-3's A1/A2/R3 findings survive
review when only one copy's tests were green.
New tests/AcDream.App.Tests/Physics/LiveEntityNetworkOnPositionCollapseMatrixTests.cs
drives 8 scenarios x 2 guid ranges (0x50xxxxxx player, 0x8xxxxxxx creature)
through the complete production OnPosition entry point. Doc comments on
ApplyRemoteContactRouting, RunRemoteArmTail, ApplyWireAirborneLeftoverBookkeeping,
TryAdoptWireCellAfterRouting, and the AirborneNoOperation throw guard
updated to describe the collapsed one-path world (the "two callers stay
one decision" claim was true before this commit and false after — fixed
in the same commit that makes it false). One branch-routing source-text
pin (LiveEntityNetworkBranchRoutingTests.cs) updated to follow the AP-140
CONTACT gate to its new address inside ApplyRemoteContactRouting.
#316 stays OPEN, deliberately not fixed here — see its updated ISSUES.md
entry.
dotnet build AcDream.slnx -c Release: 0 errors. Verified independently
bisectable at this exact commit: AcDream.App.Tests 4104/4107 (3 pre-existing
skips), AcDream.Runtime.Tests 1125/1125.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Found while scoping the OnPosition collapse and confirmed by reading the
block: the player-guid LANDING TRANSITION hard-snap syncs body and render
entity but never calls LiveEntityShadowPublisher.TryPublishRemote, while the
NPC-guid copy's tail does. Contradicts the file's own #184 Slice 2b comments.
Filed with severity UNKNOWN deliberately: the per-tick remote commit may
republish the shadow on the next tick, which would make this a ~33 ms lag
rather than the #184 invisible-but-solid class. Measuring that is the first
step, not fixing it — and it is explicitly out of the behaviour-preserving
collapse's scope.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Found by C4 route 6's integration tests (1b484937) and split out of that
zero-production closure per the standing split-on-discovery rule.
PendingSplitToWorldProjection.BuildSpawn zeroes the top-level
MovementSequence/ServerControlSequence, but its Physics.Timestamps `with`
block overrode only Position/Teleport/ForcePosition/Instance — leaving
Timestamps.Movement and .ServerControlledMove at the SOURCE item's values.
RuntimeEntityObjectLifetime.HasConsistentCreateIdentityAndParent requires the
PhysicsDesc timestamps and their flattened projections to agree, so the
synthetic spawn failed the predicate and TryRecoverUnknownPosition threw
`CreateObject 0x… has inconsistent instance or parent projections` instead of
completing the canonical create-placement transaction.
Reachable in ordinary play: retail's per-object update_times channels are
monotonic and do not reset when an item re-enters a container, so any item
that ever had world presence — dropped once, picked back up, then split —
carries nonzero values in exactly those two fields. The split pile then never
appears.
Fix is the honest value, not a placation of the predicate: a fresh split GUID
has no movement history by construction, so both channels are zero in both
projections. Deliberately NOT fixed by loosening
HasConsistentCreateIdentityAndParent — the predicate was right and the
producer was wrong.
The route-6 test that documented the throw
(SplitSourceWithRetainedMovementTimestamps_ThrowsInsteadOfRecovering) is
renamed to …_StillRecovers and now pins the fix. It asserts more than "no
throw": the result's movement channels must be ZERO in both projections, so
the test cannot pass against a lenient-predicate workaround. Sabotage-verified
in both directions — restoring the old BuildSpawn reproduces the exact
original InvalidOperationException.
Notable for the campaign record: this is a crash in the precise mechanism
route 6's scoping cited as EVIDENCE that drops already converge on the
canonical transaction. Reading the code said the path converges; driving it
said it throws. The zero-production route was still correct — and building its
tests anyway is what found this.
Complete Release suite 11,020 passed / 4 skipped / 0 failed, unchanged from
1b484937 (the test flipped its assertion rather than being added). Neither
known flake fired.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Route 6 needs no production change and this commit contains none: C3c
(529e0e9d) already flipped both hosts' Create paths onto the residence lease,
so a dropped item is byte-for-byte route 1's create classification
(RuntimePositionEntityKind.Remote, RuntimeCreateResidenceKind.TopLevel,
ClassifyCreate -> SetPosition with InitialCreateFlags = Placement | Slide).
Route 6 is a SOURCE of route-1 traffic, not a route of its own. Both drop
flavours converge on LiveEntityHydrationController.OnCreate ->
RegisterEntityWithInitialResidence — the whole-item drop through
ItemInteractionController's DropToWorld (no physics, no position; the server
decides), and split-to-world through TryRecoverUnknownPosition's call to the
identical entry point. Contract:
docs/research/2026-08-04-c4-route-6-contract.md.
Retires a FALSE PREMISE from the campaign plan (:97-100), which claimed
split-recovery creates "need an effect-replay suppression signal". Verified
against the decomp instead of assumed: play_default_script @0x005132B0 /
@0x00513300 has exactly three call sites in the entire pseudo-C dump —
DefaultScriptPartHook::Execute @0x00526c08, DefaultScriptHook::Execute
@0x00526c14, and ACCWeenieObject::DoCollision @0x0058c3b4 — and NONE from
set_description or CreateObject. Neither client plays a default script at
create, so there is nothing to suppress. acdream's only create-time replay is
the F754/F755 queue drain keyed by server GUID, which is retail's own
HandleCreateObject @0x00454C80 behaviour. The plan's other two clauses were
closed at C0 (TryCommitParent/CommitWithdrawal cancellation symmetry;
host-visible cancellation receipts); the list now states what actually
remains — route 7's child-cell two-writer split and the headless
parent-realize gap.
Retail split marking recorded for the record: UIAttemptSplitTo3D @0x0058D850
stores only splitStackSize/splitClassID/splitTime and performs no placement;
DeclareValid @0x0058E340's recovery action is SetSelectedObject @0x0058E481 —
a SELECTION transfer with a 10-second expiry, not effect suppression and not
placement. UIAttemptPutIn3D @0x0058D700 records no marker at all.
Seven tests over the now-flipped path (whole item, split stack, new-GUID
recovery, second drop, unavailable destination, newer Position after the
pending identity is consumed, plus the #314 repro), each sabotage-verified:
the production path was broken on purpose, the test was confirmed to fail,
and the sabotage reverted. R6-c is now settled by assertion rather than
argument — BuildSpawn's wholesale clone of Children/Movement/AnimationFrame/
SetupTableId is measured, not reasoned about.
FOUND WHILE TESTING — #314, filed not fixed (this route is zero-production by
contract). BuildSpawn resets top-level MovementSequence/ServerControlSequence
to 0 but its Timestamps `with` block overrides only Position/Teleport/
ForcePosition/Instance, leaving Physics.Timestamps.Movement and
.ServerControlledMove at the SOURCE item's values.
HasConsistentCreateIdentityAndParent requires the two projections to agree, so
a split whose source carries nonzero Movement timestamps — plausible for any
item dropped once, picked up, and split again — fails the predicate and throws
instead of completing the canonical transaction. Verified in source, not taken
on report. Note this is a crash in the exact mechanism the scoping cited as
EVIDENCE that drops already converge: code reading said the path converges,
driving it said it throws. Fixed in the immediately following commit.
Also filed: #313 (DeclareValid's SetSelectedObject port is missing and the
container-split flavour records no marker — selection UX, deliberately not
implemented inside a placement closure) and #315 (route 4b-3's per-packet
runTeleportHook Func<bool> closure at three RunRemoteArmTail call sites; the
network packet path, not Slice I's per-frame resolve path — filed now because
route 5 adds a fourth site). AP-124 stays open and registered.
Test lines are 410 against a 150-250 guidance, accepted: the excess is a real
ItemInteractionController harness plus the #314 repro, which is what found the
defect. A mock that proved nothing would have been shorter and worthless.
Complete Release suite MEASURED at 11,020 passed / 4 skipped / 0 failed
(baseline 11,013/4/0 at 6dc7ba51; +7 new). Neither known flake fired.
Connected gate (user-run) still owed: drop a whole item, split a stack to the
ground, drop a second within ~1 m, repeat indoors and after a portal recall,
then walk two landblocks away and back.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
User-run against the exact 6dc7ba51 Release binary with the retail UI; user
verdict "all works". The probe proves the run actually exercised the arm: 16
[remote-teleport] lines over 7 creatures, all hookRan=True placement=Committed,
every guid in the 0x8xxxxxxx creature range rather than the 0x50xxxxxx player
range — so the corrected creature-target recipe reached the NPC-guid branch
where all three NPC-arm MAJORs lived.
Recorded as a partial pass, not a blanket one: all 16 lines are
cause=teleport-ts and cause=cellless was never observed, so the cell-less half
of the same arm remains test-covered only. Folding that into "gate passed"
would repeat 4b-2's #309 shape, where every park probe shared one cause and the
unexercised cause went unrecorded.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The pinned contract plus the four review documents behind 6dc7ba51:
round 1 (retail FAIL 3 MAJOR / architecture FAIL 2 MAJOR) and round 2
(both PASS on the fixed diff).
Process findings worth carrying into the remaining routes:
1. The contract's 13 explicit "what must REMAIN true" invariants were the
fix for 4b-2's round-1 defect (a contract that said what must change but
not what must stay). They did NOT prevent a round-1 FAIL here. What the
round-1 MAJORs actually shared was a STRUCTURAL cause the invariant list
could not express: two parallel inline copies of the same routing tail.
An invariant list constrains behaviour; it cannot see duplication.
2. Both reviews independently found the same NPC synth-velocity defect
(retail R3 = architecture A2). Independent convergence on one finding is
the strongest signal this process produces — weight it accordingly.
3. The specified two-client gate could not have observed three of the four
MAJORs: it teleported a player character, and all three live on the NPC
arm. Caught by both reviewers before the gate ran, not after. Check that
a gate can structurally see the defect class it is gating.
4. A reviewer named a symbol that does not exist (RuntimeCollisionReportingState
.ForceEnd is a private helper, not the public entry point). The implementer
silently substituted the correct one (LeaveWorld) while reporting "no
disagreements with either review". Verify implementer claims against source
even when they report full agreement.
5. A new test asserted only what must NOT happen, so an emptied
ApplyWireAirborneLeftoverBookkeeping passed every test in the tree. Caught
in round 2 and closed with positive assertions, sabotage-verified. Negative
assertions alone cannot detect a deleted write.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Flips the last remote classification (SetPosition: teleport-advanced and
cell-less) onto 4b-1's RuntimeRemotePlacementDriveController, runs retail's
teleport_hook before the placement, and deletes the legacy remote-teleport
machinery. Contract: docs/research/2026-08-04-c4-route-4b-3-contract.md.
Retail: MoveOrTeleport @0x00516330's branch @0x00516386 -> teleport_hook
@0x005163EF -> SetFlags(0x1012) @0x00516414 -> SetPosition @0x00516420 ->
return 1 @0x00516438. The hook @0x00514ED0 runs BEFORE the placement and
regardless of its outcome. Retail places this branch unconditionally, at any
distance and any contact state (arg4 is read only @0x0051638E, after the
branch) — which is what retires AP-137's cell-less enqueue-vs-place delta.
D1 — the classifier's cell-less input is now the PRE-merge committed cell.
Retail's predicate is `this_1->cell == 0`, the BODY's own cell at
MoveOrTeleport entry (this_1 is assigned from this @0x00516334). acdream fed
the POST-merge canonical.FullCellId, which RefreshSnapshot ->
RefreshDerivedState -> SetFullCell has already stamped with the accepted wire
cell; a zero wire cell fails validation into RejectedData first. The shipped
remote cell-less predicate was therefore dead code, not merely different from
remotePlacementRequired. Threaded via a builder overload; route 1's overload
is untouched. The graphical !IsSpatiallyVisible arm of
projectionRequiresTeleportHook is deleted — a presentation predicate with no
retail analogue that fired the teleport machinery on a routine hot path.
Deleted: RemoteTeleportController (605), RemoteTeleportPlacement (85),
RemoteShadowPlacementSynchronizer (49), their 1,709 lines of tests, the
remotePlacementRequired predicate, the TeleportHookRequired plumbing, the
legacy pre-operation ConstrainTo fallback, and the player arm's legacy
!IsGrounded fallback. Net -2,030 lines.
Structural fix (two independent Opus reviews, round 1 FAIL/FAIL): three of the
four MAJORs were one defect — OnPosition carried two parallel inline copies of
the routing tail (player-guid, NPC-guid) that had drifted. Extracted
RunRemoteArmTail (3 call sites) and ApplyWireAirborneLeftoverBookkeeping (2),
both branches now share one implementation.
A1 ToConstraintArm mapped AirborneSnap -> AirborneNoOperation, so the NPC
arm armed ConstrainTo ZERO times for an out-of-contact wire-grounded
creature — a regression this slice introduced while closing a
structurally identical hole. Now maps to NearInterpolate; switch made
total with a throwing default proven unreachable.
R1 D2's write-nothing shape existed on the player arm only; NPC packets
fell through and wrote the body. Retail makes no player/NPC distinction.
R2 report_collision_end(this,1) @0x00514F31 was bound to
ShadowObjects.Suspend, a port of a DIFFERENT retail function
(remove_shadows_from_cells) that teleport_hook never calls. Now routes
to RuntimeCollisionReportingState.LeaveWorld, which wraps the private
ForceEnd in an admission-blocking transaction so a DoCollisionEnd
callback cannot recreate the contact table.
R3/A2 A teleported NPC synthesized ServerVelocity from the teleport distance
(~1,000+ m/s) and planned a run cycle from it. Both the install and
RemoteServerControlledVelocityCycle.Apply now gate on !isTeleportRoute.
BISECT HAZARD — A1's fix is correct only BECAUSE R1 landed. AirborneSnap is
reachable wire-airborne on the NPC arm only while D2's shape is missing there.
Reverting R1 alone silently inverts A1 into the opposite divergence: arming
where retail returns 0. Revert both or neither.
Also in the velocity hunk: the NPC block's two !IsPlayerGuid(update.Guid)
guards were dropped when it was wrapped in `if (!isTeleportRoute)`. Safe — all
five exit paths of the enclosing IsPlayerGuid block return, so the predicate is
unconditionally false below it — but it was unremarked by both reviews.
Register: AP-137 REWRITTEN (not deleted) to the surviving acdream-only
divergences — null classification during the login window and Rejected*
through UnroutedCatchUp keep a row. AD-42's RemoteTeleportController citation
retired; AP-136/AP-138 writer lists corrected to the two surviving non-Position
rebucket writers; AP-138 gains the teleport arm as a second producer of the
visible-without-collision residual (retirement path remains #309). AP-135 is
untouched and its two airborne bookkeeping writes are preserved on both arms.
AP-131 does not retire; #276 does not close.
Proof obligation 1: ParkCollisionResidents' overlap throw stays unreachable —
the teleport arm adds packets to the same TryBeginExclusiveAuthoredPlacement
one-operation-per-key machinery the far arm uses, opens no new operation shape,
and every DeferredCell outcome cancels synchronously with
restoreCancelledPark: true. The guarded property remains
HasOldPrefixPlacementDebt's stall, not a throw (4b-1's B2 caveat stands).
Correction to an earlier claim: LiveEntityPresentationController's
_activePlacementOwners was NOT write-never at HEAD —
remotePlacementRequired -> BeginPlacement -> Begin -> BeginAuthoritativePlacement
was a live writer chain. It becomes write-never BECAUSE this slice deletes that
chain, which is why deleting the dead half is behaviour-preserving.
Probe: ACDREAM_PROBE_REMOTE_TELEPORT=1 emits one [remote-teleport] line per
routed arm (guid, cause, hook-ran, placement status). TEMPORARY, strip with the
probe family.
Carried, disclosed not fixed: no dedicated bidirectional collision-partner test
for R2 (the wiring, not LeaveWorld itself, is what lacks coverage); the
stress test's teleport step drives hand-written field assignments rather than
the canonical arm; the per-packet runTeleportHook closure allocation (network
path, not the resolve path Slice I's 0 B discipline governs — file before
route 5 adds a fourth call site). B2: IRuntimeCollisionReportObserver has zero
production implementations, so retail's bidirectional DoCollisionEnd half still
reaches no gameplay consumer — this fix closes the wrong-function binding, not
that nobody listens.
Complete Release suite MEASURED at 11,013 passed / 4 skipped / 0 failed
(baseline 11,027/4/0; net -14 = ~33 deleted test cases against ~19 added).
Neither known flake fired (#302 PortalProjectionTests GC-allocation, #308
NakEmissionTests wall-clock).
STILL OWED: the two-client connected gate, which MUST use an NPC/creature
teleport target. Both round-1 MAJORs lived on the NPC arm and the velocity
cycle early-returns for 0x50xxxxxx guids, so a player target structurally
cannot observe A1, A2, or R3.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes#312 as user-confirmed, with the caveat recorded rather than buried: the
accepting session's probe capture showed 22 parks and zero park-restores, so the
restoration path was not observed executing. If an invisible-remote report
recurs, that is where to start.
Re-scopes #309 as largely superseded by #312 — the presentation restore is the
behaviour its connected check was written to probe. What survives is the
narrower faithfulness question: retail's GotoLostCell keeps a lost-cell object
hidden until reenter_visibility, where acdream re-shows it on cancel.
Adds the handoff itself: branch state and the measured 11,027 baseline, the two
known flakes and the standing instruction not to conflate them, per-route
scope with the retail addresses and the traps already paid for (teleport_hook
runs BEFORE the placement; route 5 has no possible live gate because ACE never
sends UpdatePosition for a missile; route 6 needs zero production lines and the
campaign plan carries a false premise about create-time effects; route 7 must
write the child cell at BOTH the set_parent analog and the per-commit position
analog), and the six process rules this session paid for — chiefly that the
contract causes the defect, that a slice must split on discovery, and that a
green suite and a clean-looking live session are both non-evidence.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The two gates that decide whether an accepted remote Position is interpolated
or hard-snapped read `Airborne`, which is `!Body.OnWalkable` — WALKABILITY.
Retail reads CONTACT: InterpolationManager::adjust_offset @0x00555D30 gates its
entire body on `transient_state & 1` @0x00555D52, so a retail body in contact
with a non-walkable face still interpolates.
The two predicates disagree in exactly one state — in contact, not on walkable
ground — which 204d0ae0 turned from unreachable into ordinary. Before it, the
per-tick forge made every non-airborne remote walkable by construction, so the
disagreement could not occur.
Both gates now read `!Body.InContact`: ApplyRemoteContactRouting's flight
carve-out and OnPosition's player-remote arm.
`Airborne` is deliberately NOT re-derived from CONTACT. That would perturb all
five of its writers and contradict a pinned assertion in
RemoteTeleportPlacementTests.Apply_PendingGroundToSteepContact_ (InContact:
true, OnWalkable: false -> Assert.True(remote.Airborne)); a previous
implementer attempted it and correctly backed out rather than editing the
assertion. This narrower shape touches no existing test.
AP-140's register row is retired in this commit, as the row itself specified.
Honest scope: this is a faithfulness fix, not a visible one. ACE derives its
IsGrounded flag with the same floor_z test, so during a slide it almost
certainly reports not-grounded, the classifier returns NoPositionOperation, and
neither arm is taken. Expect no observable change against ACE.
Suite 11,027 passed / 4 skipped / 0 failed (baseline 11,023).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
I closed#312 as user-passed on a two-client run that looked correct. The probe
capture from that same run shows 22 [park] lines, all cause=unplaceable, and
ZERO [park-restore] lines: no park was cancelled, so the restoration path never
executed. The session observed the intermittent failure not reproducing, which
it also did on the prior day's second recall. It neither confirms nor refutes
the fix.
Reopened to FIXED / live-gate-not-exercised, with the acceptance signal stated
explicitly: a [park-restore] ... presentation=True line for the remote's guid
under ACDREAM_PROBE_PARK=1. Absence of that line means the gate did not run
regardless of what the screen showed.
This is the same class of mistake the probe was added to prevent one commit
earlier — treating a clean-looking session as evidence that a rarely-taken path
works. The mechanism remains pinned by four tests with a seven-revert
discrimination table; only the live confirmation is missing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
#312 (cancelled park restored Runtime state but never the presentation half)
CLOSED at b1f914d5; the two-client gate passed — the recalled remote appears in
world and on radar and stays correct after going idle, which is the specific
shape that failed (a moving remote self-heals via the per-packet prologue
rebucket; only one that parks on its final Position and then goes idle sticks).
#32's remote half closed at 204d0ae0; a remote observed in acdream now slides
down a steep face under gravity instead of freezing and then blipping. Left
open and named rather than absorbed: the LeaveGround chatter bound, the !Ok
airborne latch, the contact_allows_move action-animation watch item, the AP-140
follow-up (point the two routing gates at Body.InContact rather than
re-deriving Airborne), and local-player edge-slide, which this work did not
touch.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Regression from 7f1c1f5a (C4 route 4b-2). A remote player who recalled in,
arrived, and stood still was permanently absent from the world render AND the
radar while remaining fully simulated — 71 healthy physics ticks with contact
and walkable, interpolation enqueues, equipment attached, chat visible.
Route 4b-2 is the first commit that lets an ordinary remote UpdatePosition open
a canonical SetPosition. A park publishes a synchronous Withdraw that tears down
presentation registrations; only TryPublishPlace restores them.
RestoreParkWithdrawal — added in the same slice — restores InWorld, the object
clock, and canonical residency, i.e. the Runtime half only. Eight Opus reviews
verified those three fields and the tests asserted exactly them, so the suite
stayed green while the entity was invisible.
Why it is intermittent: the presentation half IS restored incidentally by the
per-packet prologue rebucket for a MOVING remote. It only sticks when the
entity parks on its FINAL accepted Position and then goes idle, because ACE
stops broadcasting for a stationary entity, so no later packet arrives to
re-publish it and nothing else re-drives.
The fix publishes a RuntimePlacementProjectionKind.WithdrawalRestored receipt on
the one ordered placement stream, acknowledge-only in Runtime (the parked
operation is already retired by CancelCoreDeferred), which the App sink maps to
the exact inverse of its own TryPublishWithdrawal: the projection half (bucket,
IsSpatiallyProjected, IsSpatiallyVisible, spatial indexes, RefreshPresentation)
plus the publish half (_worldState, _worldEvents, _effectPoses,
_localPlayerShadow, visibility sinks). Applied with commitPose: false, because
the withdrawal never moved the sidecar; a test feeds a deliberately wrong
position to pin that.
Two alternatives were refuted on measurement, not preference. Routing the
restore's SetFullCell through CommitCanonicalCell cannot fire on the shipped
remote path at all — the prologue rebucket has already recommitted a non-zero
FullCellId before the merge cancels the park, so no cell edge remains — and it
never touches the publish half regardless. Extending RestoreParkWithdrawal
directly reduces to the same receipt, since Runtime must not reach behind the
host sink.
Gated on the entity ending the rollback canonically whole (FullCellId != 0 &&
InWorld) rather than on residencyRestored, which is false on the shipped remote
path and would have made the fix a no-op. AP-136's quiescing-prefix refusal arm
is preserved: no receipt, entity stays withdrawn.
Corrects my own framing of the defect: _worldState/_worldEvents/_effectPoses are
lost but are NOT what kills render and radar (_worldState is the plugin
IGameState; _effectPoses is the pose registry, not entity.MeshRefs). The
load-bearing casualties are the visibility sinks and the
IsSpatiallyProjected/IsSpatiallyVisible + bucket removal that gates the radar.
Register: AD-63 filed (selection deliberately not restored — user intent),
AP-136 amended (its "restored visible" claim covered only the canonical half;
the gap was a defect, not a divergence). ShadowObjectRegistry.Suspend stays
out of scope per AP-136.
Seven-revert discrimination table including one that proves the test is not
merely re-checking the bucket. Suite 11,023 passed / 4 skipped / 0 failed.
Live gate is user-run and folds into #309: two clients, ACDREAM_PROBE_PARK=1,
recall a remote in and let it stand still; acceptance is
[park-restore] ... presentation=True for that guid plus a visible model and a
radar blip.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A remote observed in acdream landed on a sloped roof and froze; the server slid
on, the gap passed AP-87's 4 m threshold, and the body snapped — the visible
blip. Live probe capture, two adjacent ticks 63 ms apart:
t=88420671 rsInContact=True rsOnWalkable=False rsIsOnGround=True
bodyCpNz=0.6097 floorZ=0.6642 steep=True gravity=True
vel=(2.146,2.264,-3.549)
t=88420734 contact=True onWalkable=True <- forced against the sweep
gravity=False <- cleared
velBeforeZero=(2.146,2.264,0.000)
moved=0.0000 <- and every tick after
The roof is 52.4 degrees against a 48.4 degree limit, so acdream's classifier
was CORRECT and was then overruled. Four independent links each froze the body
on their own: a per-tick force of Contact|OnWalkable, a per-tick velocity zero,
a Gravity clear at landing, and a landing edge testing IsOnGround
(= inContact || ...) instead of OnWalkable. The tick called
HandleAllCollisions alone — the tail of SetPositionInternal without its prefix.
Retail simulates remotes locally and derives these bits rather than asserting
them: CPhysics::UseTime @0x00509950 iterates the whole object table;
update_object @0x00515D10 gates only on parent/cell/FROZEN with no
is_player fork; SetPositionInternal @0x00515330 sets CONTACT from
contact_plane_valid @0x00515430 and ON_WALKABLE from contact_plane.N.z vs
floor_z @0x00515465-@0x0051548E before handle_all_collisions @0x005154FE;
set_on_walkable @0x00511310 fires HitGround @0x00511364 / LeaveGround
@0x00511346 edge-triggered with no ownership gate; calc_acceleration
@0x00510950 zeroes only when CONTACT && ON_WALKABLE && !Sledding @0x0051096B;
calc_friction @0x0050EE70 returns at its first line when ON_WALKABLE is clear.
acdream had copied retail's airborne no-op WITHOUT retail's local simulation.
The fix is mostly deletion: stop forging the transients, stop discarding the
authoritative velocity, stop clearing Gravity, and route the remote tick
through the same SetPositionInternal commit TickHidden and the local player
already use, with the landing edge derived from the sweep's own OnWalkable.
AP-87's threshold and conditions and InterpolationManager's node_fail_counter
snap-to-tail are deliberately untouched — this removes the CAUSE of the
divergence rather than weakening the backstop.
Cross-checked against ACE: its only creature-side VectorUpdate emitters are the
jump broadcast and spell projectiles, so integrating the wire velocity cannot
double-move a walking remote; and PhysicsGlobals.DefaultState already carries
Gravity, so deleting the manufactured State |= Gravity is safe.
Register: AP-81 narrowed (its GRAVITY half retired outright), AP-87 annotated,
AP-139 filed (the interpolation-queue clear on the landing edge), AP-140 filed
(the two routing gates select snap-vs-interpolate on walkability where retail
uses CONTACT — adjust_offset @0x00555D30 gates on transient_state & 1
@0x00555D52). AP-140's follow-up is deliberately shaped as "point the two gates
at Body.InContact", NOT "re-derive Airborne", which would perturb five writers
and collide with a pinned RemoteTeleportPlacementTests assertion.
Three gaps recorded in #32 rather than papered over: the new LeaveGround
dispatch is untested for chatter; a persistently !Ok transition can latch a
remote airborne; and — the visual-gate watch item — the deleted forge was a
blanket guarantee of Contact|OnWalkable, and contact_allows_move @0x00528dd0
silently refuses action animations without both, which is the literal root
cause of closed#270. Retail-correct on a steep face, a regression anywhere
else.
10 discriminating tests over a real PhysicsEngine landblock whose contact
normal Z is 0.61 against FloorZ 0.6642 — the live roof's exact relationship.
Suite 11,019 passed / 4 skipped / 0 failed. Includes the temporary
ACDREAM_PROBE_REMOTE_LANDING / ACDREAM_PROBE_REMOTE_SLIDE probe family that
produced the capture above; strip with the family.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Route 4b-2 landed at 7f1c1f5a and the two-client far-snap walk passed
(2026-08-04): a remote crossing 96 m in both directions stays visible and
correctly positioned at range and resumes smooth interpolation on the way in,
with no freeze, Z pop, vanish, or invisible-but-solid.
Records that #309 is still outstanding and why we know it: the
ACDREAM_PROBE_PARK=1 capture from the accepting session shows 11 parks, every
one cause=unplaceable and zero cause=quiescence, so the shared-core park change
has not been exercised live. That probe was added precisely because the prior
#309 steps could pass while broken — without it the session would have been
recorded as a full pass.
Also records the corrected 10,968 baseline and the two process lessons: the
round-1 defect traces to the contract omitting "and still advance the pose",
and the park defect should have been split into its own slice when it surfaced
in round 2 rather than riding inside 4b-2 for three more review rounds.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Flips the SetPositionSimple classification (contact, PlayerDistance >= 96 m) for
remotes onto 4b-1's drive controller and deletes both legacy far blocks, both
duplicated 96f/4f constant pairs, and both `?? Vector3.Zero` fabrications. The
4 m constant now exists exactly once. Teleport and cell-less stay legacy for
4b-3.
Retail: MoveOrTeleport @0x00516330's far branch runs StopInterpolating
@0x005163CB before SetPositionSimple @0x005163D9 and returns 1 @0x005163E8
regardless — the SetPositionError is discarded — so HandleReceivedPosition arms
ConstrainTo @0x00454272 post-move on commit AND on failure. The x87 parity
decode at @0x00516393-@0x0051639E puts exactly 96.0 on the far branch.
SetPositionSimple @0x005162B0 builds flags 0x1012 at @0x005162C4.
Non-commit outcomes still advance the body, because retail's SetPositionInternal
@0x00515BD0 commits the destination via store_position @0x00515CE2 when no cell
resolves. The partition is by STAGE, not heuristic, enforced by an exhaustive
switch: Refused/Contention/NotApplicable/RejectedPreparation store (the placement
never executed); Committed/Deferred/RejectedByPlacement do not (the engine ran
and refused, matching retail's non-storing returns @0x00515CB2 and @0x00515CD5).
Without this a refused far snap froze the remote with an emptied queue.
Also fixes a shipped defect this route made live: ParkDeferred's quiescence parks
withdrew the entity (InWorld=false, clock suspended, residency removed) and were
never restorable, while Forget(restoreCancelledPark: true) runs for every
accepted Position on every entity. The restorable decision now lives inside
ParkDeferred AFTER SnapToCell, reading body.CellPosition.ObjCellId — the value
RestoreParkWithdrawal actually restores at — against every live quiescence
rather than one minimum-OperationId token. The three pre-snap fields are hoisted
into locals because SnapToCell ends with InWorld = true. ParkCollisionResidents
passes restorableOnCancel: false explicitly; the plain unplaceable park is
provably unchanged. RestoreParkWithdrawal re-tests the prefix at restore time so
a retained route-2 park cannot re-admit into a prefix that began quiescing
during the park.
CanAttemptDestination is retained as an OPTIMISATION only, with the two Core
predicates it cannot reproduce written down at the pre-flight, plus the two
properties that depend on it staying there.
Four fix rounds and eight Opus reviews. The slice was fully green at 10,990,
10,997 and 11,004 while containing real defects — a frozen remote pinned as
correct by its own test, a fallback that over-wrote on the exact retail paths
that decline to store, and a park guard incomplete on two independent axes.
Register: AP-137 (leftover classifications take AP-87's catch-up; states the
cell-less enqueue-vs-place delta deferred to 4b-3, that RejectedData is applied
anyway, and the headless divergence), AP-138 (the refusable far placement),
AP-136 narrowed to match the relocation. #309's acceptance steps rewritten —
step 5 previously asserted a recovery the code does not perform — and gated on a
new ACDREAM_PROBE_PARK=1 signal so the check cannot pass while broken.
Suite 11,009 passed / 4 skipped / 0 failed against a measured 10,968 baseline.
The 10,973 figure recorded earlier was wrong and is corrected here.
Connected gate outstanding: the two-client far-snap walk and #309.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Route 7 wants to demote App's render-tick child rebucket
(EquippedChildRenderController.TickChild) into Runtime. Whether that is correct
depended entirely on whether retail propagates a parent's cell change to its
children. It does — via two independent mechanisms, both on the physics path.
Recursive: change_cell @0x00513390 -> enter_cell @0x00510ed0 recurses into
children @0x00510f03 passing the same CObjCell* down, each frame writing
m_position.objcell_id @0x00510f1e and cell @0x00510f35 and registering the
child individually via add_object @0x00510ee2. leave_cell @0x00510f50 mirrors
it @0x00510f84.
Per-commit: SetPositionInternal(CTransition const*) @0x00515330 has an explicit
depth-1 child loop @0x0051539c-@0x005153d8 writing child+0x4c from the parent's
curr_pos.objcell_id @0x005153bd.
The structural reason it must exist: update_object @0x00515d10 early-returns on
parent != 0 @0x00515d40, so a parented child is NEVER independently simulated.
The parent's tick is its only source of cell and frame.
This corrects the routes-6-7 scoping doc, which said retail re-cells "inside
set_parent". It does not — set_parent @0x00515a90 (both overloads read in full)
contains no cell write and delegates to change_cell @0x00515ad6.
recalc_cross_cells @0x00515a30 only READS objcell_id as a guard @0x00515a3f.
The prior UpdateChild finding is CONFIRMED: @0x00512d50 -> set_frame @0x00514090
writes m_position.frame @0x005140e9 only.
Consequence pinned for route 7's implementer: the Runtime replacement must
write the child's cell at BOTH the set_parent analog AND the per-commit
position analog. A set_parent-only write is correct at attach and stale on the
parent's first cell crossing — which is the natural misreading of the earlier
scoping.
Also recorded: unset_parent @0x00513470 does zero cell work (full body read),
leaving the child with a stale objcell_id still in that cell's object list;
all six call sites resolve it externally via leave_world @0x005155a0 (which
zeroes objcell_id @0x005155f4) or an explicit re-placement. +0x4c verified as
m_position.objcell_id by closing the offset chain against acclient.h rather
than trusting a Binary Ninja identifier. set_cell_id_recursive @0x00510da0 is a
red herring — its only caller is sky-object handling @0x00506eba.
Two adjacent facts remain NOT ESTABLISHED with the cdb breakpoints that would
settle them; neither affects the verdict.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Scoped together because both concern item/container placement; they turn out to
share no production file, but the shared inventory premise had to be corrected
once rather than twice.
The governing correction: both routes' inventory sections rest on
"RegisterEntityWithInitialResidence is never called from any production path."
C3c (529e0e9d) made that false — LiveEntityRuntime.cs:544-548 and
RuntimeLiveEntitySessionController.cs:118-123. Every conclusion reasoning from
"nothing upstream to cancel" is obsolete.
Route 6 (drops) is COMPLETE. No route-6 disposition exists: a dropped item is
Remote + TopLevel -> SetPosition/Placement|Slide, which is route 1's
classification exactly, and both drop flavors already converge on
LiveEntityHydrationController.OnCreate (split-recovery at
InventoryWorldDropProjectionController.cs:66). BuildSpawn overrides every
positional field, so the stale-source-position requirement is met.
Route 6 also retires a false premise in this campaign's own plan
(2026-08-02-placement-cutover.md:98-100): "replay create-time effects" is NOT
established as a defect. acdream's only create-time replay is the F754/F755
queue keyed by GUID, which is retail's own HandleCreateObject @0x00454C80
behaviour. The one plausible mechanism — a cloned DefaultScriptType — never
fires at create in either client; retail's play_default_script is reached only
from DoCollision @0x0058C3A0 and the hook dispatcher @0x00526C08/@0x00526C14.
And retail's "split-recovery marking" is a SELECTION transfer, not effect
suppression: UIAttemptSplitTo3D @0x0058D850 records WCID/stack/time and
DeclareValid @0x0058E340 re-selects at @0x0058E481. That port is missing but is
selection UX, not placement — file it outside C4.
Route 7 is one slice, ~300-490 lines, and must not be split: the Runtime commit
and the App demotion are two halves of one transfer. Retail performs NO
placement here — DoPickupEvent @0x00452240 is unset_parent + leave_world;
DoParentEvent @0x00452290 is set_parent + SetPlacementFrame. No SetPosition, no
leash. So route 7 inverts 4b-2's rule: never arm ConstrainTo.
Route 7's real defect: the child's canonical cell has two writers. Retail
re-cells inside set_parent (change_cell @0x00515AD6 + recalc_cross_cells
@0x00515B15); acdream commits the child cell-less in Runtime and re-cells it
from a RENDER TICK (EquippedChildRenderController.TickChild:408 ->
RebucketLiveEntity). Headless has no such controller, so every headless
parented child stays cell-less forever — the headless gap and the two-writer
split are the same bug.
Load-bearing unestablished item, recorded rather than guessed: whether retail
re-cells children when the parent crosses a cell. UpdateChild @0x00512D50 ->
set_frame @0x00514090 is frame-only and never writes objcell_id;
change_cell/set_cell's child handling was not read. Settle that before
demoting App's rebucket — demoting it blind risks route 4a's R1 / #184
invisible-but-solid.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Route 5 is RuntimePositionEntityKind.Projectile + ProjectileAuthoritative, and
the entity kind turns out to be behaviourally inert in the classifier —
ClassifyAcceptedPosition has exactly one EntityKind test (LocalPlayer, :349);
Projectile and Remote fall through identical code from :393, differing only in
OperationKind. Route 5 is an execution/ownership consolidation, not a
classification change.
Roughly half already shipped: the Create half and the residence-window Position
half are canonical and live. What remains is the post-residence accepted
Position, short-circuited before the classifier at
LiveEntityNetworkUpdateController.cs:1428-1448 — ~180 non-comment lines.
Retail has no missile branch: HandleReceivedPosition @0x00453FD0 and
MoveOrTeleport @0x00516330 route a projectile through the identical remote arm
@0x0045414D, ConstrainTo @0x00454272 IS armed for projectiles, and for an
in-flight missile with a cell and no teleport/contact retail does nothing
(return 0 @0x0051636D). This forecloses the plausible "projectiles are special"
implementation before anyone writes it.
Records that route 5 must land AFTER 4b-2 — it widens
RuntimeRemotePlacementDriveController.OwnsPlacement, which excludes
ProjectileAuthoritative today, leaving the far and teleport/cell-less branches
with no owner.
Names the gate problem honestly: ACE never sends UpdatePosition for a missile
(the one site is commented out at WorldObject_Tick.cs:333-334), so the Position
half is unreachable in ordinary play and has no cheap live trigger. The gate
covers the Create half and regressions; the four dispositions are test-gated.
Corrects a prior inventory claim: CommitProjectileCell is not ad hoc, it routes
into the shared CommitCanonicalCell (RuntimePhysicsState.cs:1376-1397). The
bypass is SnapToCell plus the InWorld/shadow tail.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
4b-2 flips one classifier branch on: SetPositionSimple for remotes, contact,
PlayerDistance >= 96 m, routed through 4b-1's dormant owner. Teleport and
cell-less stay legacy for 4b-3.
Pins the two retail facts that decide the slice: StopInterpolating runs BEFORE
SetPositionSimple (@0x005163CB before @0x005163D9), and the branch returns 1 so
HandleReceivedPosition arms ConstrainTo @0x00454272 anchored post-move. Since
MoveOrTeleport discards SetPositionSimple's error return and returns 1
regardless, the leash must be armed on refusal and rejection too — "arm on
Committed" is the natural misreading and is the same shape as the already-filed
unarmed-leash bug.
Names the trap up front: deleting the legacy far block removes the only handler
for null and Rejected* classifications, and during the login window null is
every remote packet, so remotes would not move at all until the local
controller exists. Requires a stated policy rather than a silent drop — the
same shape as route 4a's "'not Interpolate' is not 'far'" finding.
Records that AP-87's 4 m / !willBeDrTicked guards are near-branch only and are
subsumed by the far branch's unconditional snap, so they must not be carried
forward — while the near-branch copies stay, since those are 4a's and still
load-bearing.
Requires behavioural App tests explicitly: route 2 settled for a source pin
(#292) and route 4a's first attempt shipped tautologies that passed with
production reverted.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Builds the machinery route 4b-2 and 4b-3 will flip on, and changes no remote
behaviour: it has no production caller, so RemotePlacementDrivePendingCount is
provably 0 and IsConverged is unchanged.
Five pieces: a per-entity remote placement owner (RuntimeRemotePlacementDriveController),
a Position-time service-window guard with a Runtime interface plus BOTH host
implementations, N3's headless RetryPending pump, parked-count observability in
the ownership ledger, and the service-window optimisation that avoids parks we
can cheaply predict.
Landed alone because it is where the park-withdraws-the-entity failure was
decided; that decision is fixed at the source in the preceding commit and must
not share a review signal with a behaviour flip.
Two parts of route 2's controller are deliberately NOT ported, both verified
against retail rather than assumed. There is no ack: SendPositionEvent is called
only inside HandleReceivedPosition's local-player FORCE_POSITION gate
@0x0045400C-@0x00454091, and the remote arm @0x0045414D has no equivalent. There
is no re-issue funnel: retail never re-attempts a position it could not apply —
stale timestamps merely bump error_count @0x004542AC — and re-issuing packet N
after N+1 has merged would apply a pose the newer packet already superseded,
which is correct for a one-shot ForcePosition and wrong for a 5-10 Hz stream.
The service-window guard is an OPTIMISATION, not the correctness mechanism. The
original contract had it the other way round, justified by a claim that retail
cannot represent "arrived but not placeable" — false, and corrected in the
review findings: retail's GotoLostCell/reenter_visibility path represents it
exactly. A pre-flight guard also cannot be complete, because Core defers on the
entity's CURRENT cell, on the swept QueriedCellIds footprint spanning
neighbouring landblocks, and on residency evaluated after AdjustToOutside —
conditions only Core can see.
Review found and this commit fixes: DetachRoute cleared two maps of LIVE Core
operations without cancelling them (route 2's AbandonPending is the correct
mirror, not the first-entry controller) and its test asserted that blindness as
convergence; the headless predicate answered "can ever publish" rather than "is
published", and after the first fix still matched only 1 of the 9 landblocks
this host publishes; OwnsPlacement admitted remote top-level Creates until
gated on the Teleport flag as well as the disposition; Advance re-submitted
without re-checking the window; and four comments cited a report that did not
exist.
Contract item 6 is met by the structural proof, not the earlier test:
HasOldPrefixPlacementDebt refuses collision-prefix mutation permission before
ParkCollisionResidents is ever entered, so its overlap throw is unreachable.
That same mechanism is the unbounded stall filed as #310, which 4b-1 does not
bound — it only avoids widening it.
#311 files the remaining per-tick allocation in RetryPendingProjections; the
early-out for the empty-FIFO case landed via a new HasPendingReceipts accessor
so hosts still never touch .Placements. directly.
Gates: complete Release solution 10,973 passed / 4 skipped / 0 failed (baseline
10,938). Four review rounds; every fix discrimination-verified by revert.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Shipped-code defect affecting committed route-2 code, found while reviewing
route 4b-1.
RuntimeSetPositionState.ParkDeferred withdraws an entity from the world:
body.InWorld = false, TransientStateFlags.Active cleared, WithdrawCanonical,
SuspendObjectClock. CancelCoreDeferred then removed the operation and rewrote
the pending Withdraw into a Discard while restoring NONE of it. So cancelling a
wakeable park was strictly worse than keeping one — the park is wakeable, the
cancel destroys the only object that could ever wake it, and the entity is left
invisible AND intangible with nothing to bring it back.
Route 2's re-issue funnel masked this: re-issuing is correct for a one-shot
ForcePosition ACE never repeats, and wrong for a repeated remote stream, so the
hole was hidden rather than fixed.
Retail's own answer is a working park, verified in the decomp rather than
assumed: CPhysicsObj::SetPositionInternal @0x00515BD0, when AdjustPosition
yields no cell @0x00515C1D, calls prepare_to_leave_visibility @0x00515CDA,
store_position @0x00515CE2 (the DESTINATION pose is committed), GotoLostCell
@0x00515CF2 registering at m_position.objcell_id read AFTER store_position (so
the destination cell), clears transient 0x80 @0x00515CF7, and returns OK
@0x00515D07. InitObjCell @0x00508260 drains the lost list on cell load and calls
reenter_visibility @0x00516250, which re-places from the object's OWN
m_position with flags 0x11.
Two corrections to the direction I gave, both forced by evidence and both right:
The pose must NOT be rolled back — only the withdrawal. Three shipped route-2
tests capture positionAtPark AFTER the park and assert it survives the cancel,
and retail agrees: store_position commits the destination and nothing
un-commits it. Restoring residency at the body's committed cell is therefore
retail's own cell choice, not merely self-consistent.
The gate defaults to FALSE with four explicit opt-ins, rather than defaulting
true with opt-outs at the withdrawal callers. That keeps every one of the ~20
shipped Forget/ForgetExactPlacement sites at exactly its current behaviour
instead of depending on having correctly enumerated the withdrawal transactions.
Review had already found the broad version corrupting five of them
(TryApplyPickup, CommitAcceptedParent, CommitAcceptedParentCellless,
CommitWithdrawal, CommitPositionChannelUpdate): they hand-roll a partial
re-withdrawal that undoes the clock and FullCellId but not InWorld or the
_spatialRoots re-registration, leaving a picked-up item both in inventory and an
InWorld cellless spatial root in the physics workset.
ParkDeferred's restorableOnCancel is opt-in for exactly one of its four callers
— the plain unplaceable-destination park. Every quiescence and retirement park
is excluded deliberately: those entities are withdrawn because their world is
going away, and restoring residency inside a quiescing prefix blocks its
retirement.
VerifyPositionChannelCancellation now asserts InWorld and IsSpatialRoot per
channel — Position is a cancellation and must restore; Pickup and Parent are
withdrawals and must not. It previously asserted only !IsDeferred and counts,
which is why five green states hid this.
Register row AP-136 measured against GotoLostCell/reenter_visibility rather than
labelled "retail-shaped". Files #309 (the restore-on-cancel residual, with
park-survives recorded as the retail-faithful target and its two blockers named:
the NewerPositionPickupAndParentEachCancelExactLostOperation invariant and
teardown convergence) and #310 (an unbounded retirement stall — a retained
preparation retry pins its prefix through HasOldPrefixPlacementDebt forever, and
TickLostCellDeadlines has no production caller so the 25 s timer never fires).
This is a user-observable change to shipped paths: restorableOnCancel: true sits
in SubmitPreparedPlacementCore, the shared core behind every production
placement. AP-136 and #309 carry the proposed two-client check.
Gates: complete Release solution 10,973 passed / 4 skipped / 0 failed (baseline
10,938). Every new test discrimination-verified by reverting the fix.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
4b-1 builds the machinery and changes no remote behaviour: a per-entity remote
placement owner, a Position-time service-window guard on both hosts, the
refuse-rather-than-park policy, N3's headless RetryPending pump, and parked-count
observability. No production caller, so 4b-2 and 4b-3 flip it on afterwards.
It lands alone because it is where the park-withdraws-the-entity failure mode is
decided, and that decision needs its own review signal rather than sharing one
with a ~700-line class deletion.
Pins the central decision: refuse rather than park. A DeferredCell park withdraws
the entity (InWorld false, Active cleared, clock suspended, residency dropped),
and Forget-on-every-accepted-Position kills the park without restoring any of it
— so a remote that parks and is then superseded by an Interpolate packet stays
withdrawn indefinitely, invisible and intangible.
Names the two transfer errors that would look correct to anyone copying route
2's controller: do not port the ack machinery (retail's remote arm has no
SendPositionEvent) and do not port the re-issue funnel (re-issuing a superseded
pose is wrong for a repeated 5-10 Hz stream).
Makes ParkCollisionResidents' overlap throw a gate item rather than a note — it
is unreachable today only because steady-state remotes hold no operations, and
with N remotes an ordinary streaming retirement would become session-fatal.
Lists what 4b-1 must not touch, including AP-135's two writes (4a-owned
dispositions that sit inside the method 4b rewrites — the trap) and the single
retail ConstrainTo arming site.
Records both known flakes by number and mechanism so they cannot be conflated
again: #302 is a GC-allocation assertion in App.Tests, #308 a wall-clock deadline
in Core.Net.Tests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The user live-tested route 4a and reported two defects on player remotes: a
remote holds the falling animation after landing before finally landing, and a
remote jumping onto a house plants on the roof where retail slides off, then
blips to the slid-down position.
Neither is a route 4a regression. Do NOT revert 44830a0e — reverting would
restore the per-packet render slam 4a removed without touching either defect.
Bug B's root cause is identified and already covered by open issue #32, whose
text names both symptoms in one sentence. Both landing sites assert
TransientState |= Contact | OnWalkable unconditionally, where retail derives it
from the contact plane — CPhysicsObj::SetPositionInternal @0x00515330
(`if (contact_plane.N.z < floor_z) set_on_walkable(0) else set_on_walkable(1)`).
A steep roof is contact but NOT on_walkable; asserting both suppresses the slide
response, so the body sits until the server's positions walk 4 m away and
AP-87's threshold snaps it. That is the blip. Verified byte-identical pre-4a via
`git show 19d95094:`.
Bug B's *visible shape* IS 4a's: pre-4a every packet slammed the render entity
to the wire pose, so a stuck body flickered toward the true sliding position
5-10x per second — jitter rather than a clean hold.
Bug A stops at the goal's stop-condition rather than getting a speculative fix.
Three hypotheses with non-overlapping fixes; picking wrong means changing a
retail-ported gate on a guess. Retail's mechanism is already fully decoded, so
what is missing is OUR runtime state — no cdb trace against retail is needed.
Adds ACDREAM_PROBE_REMOTE_LANDING (PhysicsDiagnostics, read once at startup per
the diagnostic-owner rule, one bool check when off). It logs both landing sites
immediately before HitGround, and — the most diagnostic signal — emits a
separate line when a site is reached but the gravity gate is about to no-op,
which is hypothesis 1 (a wholesale Body.State write wiping the transient Gravity
bit mid-air, exactly AP-81's stated risk). Temporary instrumentation, marked for
stripping once the evidence is in.
Evidence recorded rather than new bugs filed: #32 gains the observation, the
root cause and the #173/AD-10 dependency caveat; AP-87 gains a live instance of
its stated risk; AD-10's stale file:line is corrected to RemoteMotionCombiner
with a note that its terrain-only normal cannot see a house roof at all.
Also files #308 — a SECOND flaky test, distinct from #302, which was twice
misattributed to it before being written down. #302 is a GC-allocation assertion
in App.Tests; #308 is a wall-clock deadline loop in Core.Net.Tests that fails
only under full-suite CPU contention (0 failures in 4 isolated runs). Conflating
them hides one, and an agent told to "ignore the known flake" would wave through
a real transport regression.
Gates: complete Release solution 10,938 passed / 4 skipped / 0 failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Scoping at 44830a0e puts 4b at 1,300-2,200 production lines (centred ~1,700)
plus ~2,500-3,500 lines of test work — 4-6x route 4a and ~2x route 2, the two
largest landings in this campaign, which took 4 and 5 review rounds. Split into
4b-1 (infrastructure, no behaviour change), 4b-2 (far branch), 4b-3 (teleport
and cell-less, with the ~739-line class deletions). 4b-1 stays separate
regardless of appetite for landings.
Corrects two errors in documents from yesterday:
AP-135 does NOT retire with 4b. Its own condition is retirement with the
free-fall sweep gate, which 4b does not touch, and its sites are the airborne
no-op branches — 4a-owned dispositions. The trap is that its two writes sit
inside OnPosition, which 4b rewrites heavily.
Retail has exactly ONE ConstrainTo on the remote arm (@0x00454272); all three
nonzero-returning MoveOrTeleport branches funnel through it. My route-4 scoping
implied a distinct remote-teleport arming site. There is none, so 4b must not
add a second one — the post-operation arm 4a introduced becomes the only arm.
Records a new failure mode 4b must not create: a DeferredCell park WITHDRAWS the
entity (InWorld false, Active cleared, clock suspended, residency dropped), and
Forget-on-every-accepted-Position kills the park without restoring any of it. If
the next packet classifies Interpolate, no placement runs and the remote stays
withdrawn indefinitely — invisible AND intangible, the #184 class through a
third door. Direction: refuse rather than park; the next packet is the retry,
because remote Positions are a 5-10 Hz stream.
Two transfer errors named explicitly so they are not repeated: do not port route
2's re-issue funnel (re-issuing a superseded pose is wrong for a repeated
stream), and do not port its ack machinery (retail's remote arm has no
SendPositionEvent).
Also records that remotePlacementRequired fires for every non-visible remote on
the graphical host — a routine hot path, not a teleport rarity — and that
deleting the legacy blocks removes the only handler for null/Rejected*, which
during the login window is every remote packet.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Routes the classifier's two NO-PLACEMENT remote branches — Interpolate
(contact, PlayerDistance < 96 m) and NoPositionOperation (no contact) — through
a Runtime-owned seam, and fixes the two divergences they carried. Teleport,
far-snap and cell-less stay on the legacy App path; 4b owns them.
Route 4 was split into 4a/4b after scoping put the whole route at 1,500-2,500
lines against a ~400 budget. 4a's branches perform no SetPosition, so this slice
carries no deferred-cell park, no service-window guard and no allocation
exposure — which is what made the split worth doing.
Divergences fixed, both previously unfiled:
* D1 — the NPC airborne branch hard-snapped Body.Position/Orientation and
branched on the client-tracked rmState.Airborne, never consulting the wire
IsGrounded bit. Retail's MoveOrTeleport @0x00516330 returns 0 at 0x0051636D
and writes nothing. Player remotes were already correct; NPCs were not.
* D2 — ConstrainTo was armed before the operation, unconditionally, so it fired
on the airborne no-op retail skips and anchored to the PRE-move position.
Retail arms it at 0x00454272, only when MoveOrTeleport returns nonzero,
anchored to &arg2->m_position read live, i.e. post-move.
AP-87 and TS-44 were carried deliberately, not delegated away. AP-87's three
conditions — including firstUp, which one round silently dropped — are preserved
as an explicit acdream policy layer applied AFTER the classifier commits to
Interpolate; the two previously separate player/NPC copies are now one. TS-44
stays an NPC-only caller gate; extending sticky suppression to player remotes has
no retail basis and no live evidence, so it was declined rather than absorbed.
Landing is explicitly carved out of 4a's ownership on both arms. A landing packet
classifies Interpolate, so an ordering slip would ENQUEUE a body that must PLANT
and a creature knocked off a ledge would glide down over a packet interval. The
carve-out is a named entry point returning AirborneSnap/SteadyStateInterpolate/
Legacy precisely so the PRECEDENCE is observable and testable rather than implied
by statement order — that is how the slip happened once and was caught.
The player/NPC asymmetry on landing is real and NOT resolved here: retail draws
no such distinction, but converging them is a behaviour decision needing its own
evidence. Filed into the 4b plan.
Register: AP-135 filed for the two bookkeeping writes the airborne branch
deliberately retains (rmState.CellId, LastServerPos/Time) — not retail's model,
but load-bearing for our catch-up sweep and staleness timer, and verified not to
be a canonical cell commit for ordinary remotes. AP-87 and TS-44 rewritten to
describe the code.
Honest remainder: App still owns branch selection, the airborne return, the cell
write, the entity write and the shadow publish, and headless satisfies "both
hosts drive the identical entry point" only vacuously since it returns early for
remotes. That is written into the 4b bullet rather than left implicit.
Cost: 364 non-comment production lines, 91% of the ~400 budget — the split did
isolate the cheap half, but not by much. Do not carry "well under" into 4b's
scoping.
Gates: complete Release solution 10,938 passed / 4 skipped / 0 failed (pre-4a
baseline 10,909). Four review rounds; the first three each introduced a new
behavioural defect while fixing another, and each left a comment asserting
behaviour that no longer matched — the final round's precedence matrix was
traced cell-by-cell against HEAD with only the D1-intended difference. App tests
call production entry points against a real WorldEntity and real classifier
output, closing route 2's #292 gap rather than repeating it.
Connected acceptance NOT run — needs a live second character.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Shipped defect in route 2 (9966b531), found while reviewing route 4a.
`InboundPhysicsStateController.TryApplyPosition` built its AcceptedPhysicsTimestamps
via `Current(gate, teleportAdvanced: ...)`, omitting `previousTeleport`, which
defaulted to a literal 0. The only site that populated it was the deferred
initial-create path — which is why the continuation executor was correct and
every newer consumer was not.
Consequence in shipped code: route 2 feeds this into
`ValidAcceptedAuthority`, which requires Previous == Accepted for a
ForcePosition. Any local player whose TELEPORT_TS is nonzero — anyone who has
portalled or recalled this session — had the authority rejected and the force
correction SILENTLY DROPPED. The user's @pklite acceptance was genuine but
narrow: that character had not teleported, so the stamp was still 0.
Second latent consequence: with an accepted stamp >= 0x8000, wrap-safe
TeleportRegressed also fires against the 0 and rejects ordinary Apply positions,
not just ForcePosition.
The fix captures `previousTeleport = gate.TeleportTimestamp` BEFORE
`TryAcceptPositionEvent` mutates it, matching the shape the deferred path
already used. Ordering is the whole point: capturing after would make
Previous == Accepted unconditionally, so ValidAcceptedAuthority's check would
pass vacuously — the symptom would disappear while the semantics broke.
Also removes the footgun that allowed it. `Current`'s parameter is now
`ushort? previousTeleport = null` resolving to `gate.TeleportTimestamp`, so the
eleven non-Position channels — none of which can move TELEPORT_TS — get
"previous == current" by omission rather than a literal 0 that is
indistinguishable from a genuine "never teleported".
Consumer audit: only TryApplyPosition was defective. The two route-2 call sites
trace back to it; the RuntimeEntityObjectLifetime sites source from
TryAcceptDeferredPosition and were already correct.
Tests discrimination-verified by reverting the argument to 0: the stamp test
fails Expected 10 / Actual 0, and the classifier test fails Expected
SetPositionSimple / Actual RejectedAuthority — the shipped defect reproduced
exactly.
Gates: complete Release solution 10,935 passed / 4 skipped / 0 failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
User saw "[System] WeenieError 0x0504" on login after a PK Lite reversion.
0x0504 is YouAreNonPKAgain; only ~56 of 378 codes had strings, so the raw hex
fallback fired.
Retail's source is ClientCommunicationSystem::HandleFailureEvent @0x00571990 —
a switch with per-case literal UTF-16 strings, not a DAT string-table lookup, so
hardcoding them is retail-faithful.
The decomp could not be trusted for the text. Its dump of data_7d32c0 declares
[0x5f] and shows 95 characters ending mid-word at "...protection of the Lig".
The real string is 139 characters. The 0x5f is Binary Ninja's PREVIEW
TRUNCATION LENGTH, not the array size — worth remembering for the rest of the
switch, since a copy-paste from the dump would have shipped a truncated
sentence. Recovered by PE byte read (VA 0x007D32C0 -> RVA -> .rdata file
offset), cross-confirmed against the raw hex the pseudo-C carries immediately
after the preview.
Mapped 0x0504, 0x0505, 0x04EC, 0x04ED, each byte-verified and cited with its
case address. Retail's trailing newline is dropped deliberately (documented
in-comment): acdream renders one ChatEntry per system message where retail has a
single scrolling buffer. Adjacent codes are deliberately left unmapped with a
test pinning that 0x04EE still falls back to hex — a wrong message is worse than
a raw code.
Files #306 for the full port, with three findings that make it more than a
string table: the switch is SIX compiler-lowered blocks spanning 339 distinct
case values from 0x17 to 0x593, not one contiguous band; retail passes a colour
argument with three values in use (0 x162, 0x1a x113, 7 x59) and acdream's chat
has no colour concept; and HandleFailureEvent aborts an in-progress automatic
attack on 0x43/0x3f7/0x3e/0x23/0x36 — verified against the decomp, with the
nuance that 0x43 has no display case at all and is abort-only, so that one is a
pure gameplay gap.
Gates: complete Release solution 10,909 passed / 4 skipped / 0 failed (baseline
10,904; +5 = the five new tests).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
User-directed after scoping put whole-route 4 at 1,500-2,500 production lines
against a stated ~400 budget.
4a is the steady state: the classifier's Interpolate (contact, < 96 m) and
NoPositionOperation (no contact) branches. Neither performs a SetPosition, so 4a
carries no deferred-cell park, no service-window guard, no allocation exposure,
and no interaction with the Forget-on-every-accepted-Position behaviour that
dominated route 2's review rounds. It also fixes two of the three unfiled
divergences: the NPC airborne hard-snap that ignores the wire IsGrounded bit
(retail returns 0 and writes nothing, MoveOrTeleport @0x0051636D), and
ConstrainTo armed before the operation instead of after (retail arms it post-move
only on a nonzero return, @0x00454272).
4b takes the edges — teleport, far-snap, cell-less — where the parks, the
Position-time service-window guard, #277's broken bound, N3, and the third
divergence live.
The contract sanctions exactly one dual path: 4a routes its two classifications
through the new seam and leaves the other two on the legacy path until 4b. That
is a staged cutover rather than a duplicate authority ONLY because the
discriminator is the classifier itself and the classifications are mutually
exclusive; the contract says so explicitly and requires the fallback deleted in
4b.
Two carried acdream additions are called out as load-bearing rather than left to
be discovered: AP-87's 4 m / !willBeDrTicked snap conditions (which prevent the
#184 invisible-but-solid monster and are NOT in the classifier) and TS-44's
sticky suppression. Silently dropping them by delegating to the classifier is
named as the failure mode.
Acceptance requires a BEHAVIOURAL App test, not the source-text pin route 2
settled for (#292).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The user confirmed melee and bow now work against a PKLite player in a live
two-client session ("melee and bow works, all good"). That accepts #298
directly, and #297 indirectly but conclusively: the both-PKLite arm of
ObjectIsAttackable cannot pass unless the LOCAL player's own PKLite bit is
live, which is exactly what #297 fixed.
Not separately confirmed by the user and therefore NOT recorded as accepted:
the collision-after-equip case (#297's round-2 defect) and combat-camera
tracking (#298's second site). Both are implemented, suite-green and
review-passed; they remain unverified by observation.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Marks #297 (9b1e6fc6), #298 (bc0077a5) and #299 (88348f67) DONE per the
issue-tracking rule, and adds a handoff covering what landed, what still needs
the user's eyes, and the route 4 decision waiting on them.
Three items are implemented and suite-green but NOT user-verified: collision
with PKLite players (including the equip/unequip case round 1 got wrong),
melee/bow on a PKLite player plus the auto-target guard, and combat-camera
tracking of a PKLite opponent.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Selecting a PKLite player and attacking did nothing: with auto-target on it
retargeted to the nearest monster, with auto-target off it logged
"combat: attack ignored; no creature target found". Spells on the same target
worked, which was the clue.
Root cause: CombatTargetPolicy.IsHostileMonster:31-33 rejects any candidate
carrying BfPlayer BEFORE reaching ObjectIsAttackable, so the both-PKLite pool
match at SelectedObjectHealthPolicy.cs:70-71 was unreachable for players. Melee
and missile targeting never supported player targets at all — the gate is named
IsHostileMonster and does exactly what it says. Nobody could hit it until
69ba9486 made PK Lite reachable.
Retail uses ONE predicate for monsters and players, with no player exclusion:
ClientCombatSystem::ExecuteAttack @0x0056BB70 gates unconditionally on
ObjectIsAttackable @0x0056A600 (creature type, Free-PK short-circuit on either
side, then IsPlayer -> bothPK || bothPKLite, else BF_ATTACKABLE with pets
excluded). acdream already ported that predicate verbatim; it was simply
unreachable.
The fix SPLITS the two concerns rather than relaxing the shared predicate:
explicit-target admission routes through ObjectIsAttackable, while auto-target
ACQUISITION keeps the monster-only gate. That is required by register row
IA-19 — explicit product direction that Auto Target must never select NPCs,
players or pets. IA-19 is not overridden here; its own justification promises
"manual player-selection commands remain available", and that promise was never
implemented, so this makes the row true. Review confirmed no path lets
auto-acquisition select a player: every automatic Select is fed by a
FindClosest* filtered through IsHostileMonster.
Review also found a second site with the same bug, which the first pass froze in
place on my instruction: retail gates combat-camera tracking on the SAME
predicate as the attack. ClientCombatSystem::UpdateTargetTracking @0x0056A950
reads GetAttackTarget() then gates CameraSet::TrackTarget on ObjectIsAttackable.
Ours used the monster-only gate, so with ViewCombatTarget on by default the
attack would land while the camera refused to track the opponent — user-visible
in exactly the duel this fix enables. GetCombatCameraTargetPoint now uses the
wide predicate. IA-19 does not reach the camera: it performs no acquisition,
only presentation on an already-chosen target. The first pass had added a
source comment asserting IA-19 covered it; that comment and the matching text in
docs/ISSUES.md are corrected, since a wrong citation is how a real divergence
becomes invisible.
Depends on 9b1e6fc6 (#297): the both-PKLite arm needs the LOCAL player's own bit
to be live. Review confirmed both admission sites read ClientObjectTable on every
call, so this is not inert in production.
Newly reachable and now pinned: ObjectIsAttackable's pet-exclusion arm, which
CombatTargetPolicy rejected before it could ever run.
Follow-ups filed: #304 (SelectionInteractionController.GetSelectedOrClosestCombatTarget
has no production caller — one of the two widened call sites is dead code),
#305 (HeadlessGameplayOperations has the identical pre-existing bug, so the
graphical/headless hosts now diverge).
Gates: complete Release solution 10,904 passed / 4 skipped / 0 failed (baseline
10,900). Adversarial + retail-conformance review PASS after one FAIL round; the
predicate was re-verified branch-for-branch against 0x0056A600 since it goes
live here for the first time. Camera fix discrimination-verified by revert.
Connected acceptance NOT run — needs a live two-client PKLite session.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The user typed @pklite and then walked straight through other PKLite players.
Root cause: ClientObject.PublicWeenieBitfield was written exactly once, from the
0xF745 CreateObject parse, and never refreshed. ACE's only PK-change message is
PropertyInt.PlayerKillerStatus (134) over 0x02CE/0x02CD, which we parsed and
stored into Properties.Ints[134] but never translated back into the bitfield —
and ACE never re-sends a PublicWeenieDesc at all (EnqueueBroadcastUpdateObject
has zero live callers), so that property is the ONLY signal a client can learn
from. Both sides of the collision test read the frozen value, so
CollisionExemption's "4c. both PKLite -> collide" rule could never fire.
Retail's missing port: PublicWeenieDesc::SetPlayerKillerStatus @0x005AC7C0
rewrites _bitfield in place — PK(4) -> (b & 0xfddfffff) | 0x20; PKLite(0x40) ->
(b & 0xffdfffdf) | 0x2000000; Free(0x20) -> (b & 0xfdffffdf) | 0x200000; else
b &= 0xfddfffdf. Mutually exclusive, verified byte-for-byte, with input values
confirmed against retail's own PKStatusEnum (acclient.h:6412-6427), not just
ACE's. Driven from ACCWeenieObject::OnStatUpdated @0x0058DF20 case 0x86.
The fix rewrites the value at its source rather than patching consumers. Two
review rounds were needed because the first pass missed that there are TWO
snapshot stores: InboundPhysicsStateController keeps its own private _snapshots
dictionary, and every untimestamped-field merge (ApplyAcceptedObjDesc and
friends) reads `old` from THAT store, not from RuntimeEntityRecord.Snapshot.
Refreshing only the active record left the target-side shadow flags correct
until the remote's next equip or unequip — ACE broadcasts an ObjDesc on every
one — at which point the appearance path rebuilt the registration from the
frozen spawn and dropped the bit permanently. The regression test demanded by
review is what surfaced that; it is verified discriminating (reverting gives
Actual: 8 instead of 33554440).
Five stores now hold this value, kept coherent from one source by two
ObjectUpdated subscribers plus the appearance-rebuild path. The two shadow-flag
writers are the same invalidation applied at the two edges that can invalidate
it, not competing authorities — review enumerated every drift path and closed
each. That coherence invariant is new as of this commit and is recorded as
register row AP-134, with AP-133 as the precedent for filing a row when the
danger is a future writer rather than current behaviour.
Also corrects TS-23's retirement narrative, which claimed every mover-flags call
site read the mover's "real" PK bits from 2026-07-30. The bits existed but their
source was frozen, so that only became true here; the site enumeration also
missed RuntimeSetPositionMoverPreparation, a seventh site that decodes the
snapshot directly.
Unblocks #298 (melee/missile admission needs the local player's own PKLite bit).
Follow-ups filed: #300 (Properties.Ints[134] vs bitfield mirror gap), #301 (same
defect class for radar blip colour and radar behaviour), #302 (a pre-existing
PortalProjection allocation-assertion flake, 1 in 6, found while verifying this
gate), #303 (LiveEntityPvpBitfieldSync is App-resident but Runtime-owned-state).
Gates: complete Release solution 10,895 passed / 4 skipped / 0 failed (baseline
10,887 including #299). Adversarial + retail-conformance review PASS after one
FAIL round. Every new test discrimination-verified by reverting the fix.
Connected acceptance NOT run — needs a live two-client PKLite session.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CollisionExemption checked only the TARGET's IsImpenetrable, and the class doc
asserted "retail's pseudo-C only checks the target's IsImpenetrable(); acdream
follows retail" while blaming ACE for checking both. That was backwards: ACE was
retail-faithful and acdream was missing half the check.
Retail short-circuits on EITHER the mover's own state & IS_IMPENETRABLE (0x80)
OR the target's IsImpenetrable(); either alone exempts. Verified at the byte
level rather than from the decompiler's rendering — Binary Ninja shows the mover
test as `int16_t state_1 ... if (state_1 < 0)`, which reads like a 0x8000 test,
but decoding the PDB-paired binary at the mapped offset gives:
8b 43 04 mov eax,[ebx+4] ; mover object_info.state
f6 c4 01 test ah,1 ; 0x100 IsPlayer
84 c0 test al,al ; sign bit of AL = state & 0x80
78 3d js ... ; -> collide
`test al, al; js` is a byte-level sign test on AL, i.e. 0x80, not 0x8000.
Corroborated downstream in the same block (`test ah,8` = 0x800 IsPK,
`test ah,0x10` = 0x1000 IsPKLite) and by OBJECTINFO::init @0x0050cf30 setting
state |= 0x80 from the object's own IsImpenetrable().
Also corrected: ACCWeenieObject::IsImpenetrable @0x0058c8c0 returns
(_bitfield >> 0x15) & 1 — retail genuinely conflates BF_FREE_PKSTATUS with
"impenetrable", so acdream's FromPwdBitfield decode was already right.
Both retail arms set collide, so ordering between them is semantically free and
a misreading here could only ever produce spurious collisions, never a
walk-through.
Found while investigating #297; not symptom-causing on its own. No divergence
row: this retires a missing port rather than introducing a deviation, and
nothing in the register or the collision digest's DO-NOT-RETRY tables covers it.
Gates: complete Release solution 10,887 passed / 4 skipped / 0 failed
(baseline 10,867/4/0). Adversarial + retail-conformance review PASS on this
change specifically. Both new tests discrimination-verified by reverting the
branch and confirming failure.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
After route 2 I pinned a falsifiable bet: routes 4-7 reuse the seam route 2
built, so their marginal cost should be well under 400 production lines, and if
route 4 also cost ~900 the bet was dead. Scoping estimates 1,500-2,500 lines
plus ~1,700 lines of test re-modelling. Honouring the bet: no implementation
pass until the scope is re-planned.
The bet failed for an instructive reason. The seam generalises fine — the
begin/prepare/submit chain has no local-player precondition, the classifier's
remote branches are already retail-exact, and all remote physics state is
already in Runtime. Route 2 was simply not a representative unit: one entity vs
N, one disposition vs four, one execution path vs two (canonical SetPosition
AND the interpolation queue), no teleport hook, no constrain phase, two
duplicate authorities vs six. Picking the simplest route first and then
calibrating everything against it was the error.
Four findings that change the campaign plan, not just route 4:
- Route 4's Create half is already done (C3b/C3c). The remaining work is
steady-state remote Position plus deletions; the route title misleads.
- AP-131 cannot be retired by route 4. Route 2 did not fix its FORCE_POSITION
half, and its local ordinary-Apply half is owned by no route in the inventory.
- #277's safety bound breaks: it argues about Creates, while a steady-state
Position can carry a remote out of the collision window with no Create at all.
Needs a Position-time service-window guard on both hosts; the graphical host
has no such predicate today.
- N3 (headless never calls RetryPending) stops being latent the moment route 4
makes headless remotes produce placement receipts.
Also records three previously unfiled divergences found while scoping: the NPC
airborne hard-snap that ignores the wire IsGrounded bit, ConstrainTo armed
before the operation instead of after, and ConstrainTo never armed on the remote
teleport branch. Route 4 fixes all three by construction, which makes it a
behaviour change to every visible creature rather than a refactor.
Allocation is NOT the blocker the inventory feared: the steady state classifies
to Interpolate, which runs no SetPosition at all.
Recommends splitting route 4 into 4a (near/interpolate + airborne no-op — the
observable win, no park hazard) and 4b (teleport/far/cellless — where the parks,
the service-window guard, N3 and #277 live).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The user found three symptoms live within minutes of 69ba9486 making PK Lite
reachable for the first time. Two independent root causes, neither a C4 route 2
regression (verified by diff: 9966b531 touched none of the gates, and all three
predate it).
#297 (HIGH) — PublicWeenieBitfield is written once at CreateObject and never
refreshed. ACE's only PK-change message is PropertyInt 134 over 0x02CE; we
store it but never translate it into the bitfield, and ACE never re-sends a
PWD (EnqueueBroadcastUpdateObject has zero live callers), so a client cannot
learn PK status from the bitfield after login. Both sides of the collision test
read the frozen value, so CollisionExemption's "both PKLite -> collide" rule
never fires. Retail's missing port is PublicWeenieDesc::SetPlayerKillerStatus
@0x005AC7C0, driven from ACCWeenieObject::OnStatUpdated @0x0058DF20 case 0x86.
#298 (MEDIUM-HIGH, blocked on #297) — CombatTargetPolicy.IsHostileMonster
rejects BfPlayer before reaching ObjectIsAttackable, so the PKLite pool match
we already ported correctly is unreachable for players. Retail uses ONE
predicate for monsters and players with no exclusion. Critically: the naive fix
is wrong — the same predicate backs auto-target acquisition and the combat
camera, and relaxing it would violate register row IA-19's explicit product
direction. The fix must SPLIT explicit-target admission from auto-acquisition,
which is what IA-19's own unimplemented promise already describes.
#299 (LOW) — CollisionExemption checks only the target's IsImpenetrable while
retail short-circuits on mover OR target, and the class doc asserts the
opposite. Found during the investigation; not symptom-causing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The user provoked a real ForcePosition via the @pklite entry-collision bump
and observed the visible slide off the overlapped character, correct
animation, no heading change, and no leash tethering afterwards. That accepts
both named behaviour changes live: the ack now fires after the canonical
commit, and the ForcePosition route no longer re-arms the constraint leash
(retail's force branch returns at 0x0045409D, ahead of all three ConstrainTo
sites).
Route 2 is complete and accepted at 9966b531. Routes 3-7 remain open.
Also records what shipping @pklite exposed, explicitly NOT a route 2
regression: PK Lite became reachable for the first time and melee/ranged
attacks refuse a PKLite target while spells on the same target work. Under
investigation, filed separately.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Rewrites the visual gate now that 69ba9486 makes the ForcePosition lever exist.
Records the finding that would otherwise cause a false pass: admin teleports
(@teleto/@teletome/@teleloc/@movetome) advance ObjectTeleport, not
ObjectForcePosition (PositionPack.cs:49-52), so they exercise route 3. ACE
advances ObjectForcePosition in exactly two places and only the PK Lite
entry-collision bump is reachable by command.
Flags the one-shot nature of the test: entering PK Lite is a persistent
character state change, and DoPKLite @0x0057A490 rejects every later attempt
once IsPlayerKiller @0x0058C910 is true. The two no-op checks come first so the
state-changing step is last.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
acdream never implemented @pklite. It is a CLIENT command in retail, not a
server one — ACE has no pklite text-command handler — so typing it forwarded as
inert chat text that the server ignored.
Retail: ClientCommunicationSystem::DoPKLite @0x0057A490 rejects with
WeenieError 0x507 when ACCWeenieObject::IsPlayerKiller @0x0058C910 is true
(that returns true when EITHER the PK bit 0x20 OR the PKLite bit 0x2000000 is
set), prints "Please see @help pklite for more..." and sends nothing if given
any argument text, and otherwise calls CM_Character::Event_EnterPKLite
@0x006A13F0 — a bare 12-byte parameterless game action, opcode 0x28F, the same
shape as Event_LoginCompleteNotification beside it. Verb string at 0x007E16B0,
help text at 0x007DF0C8, failure string at 0x007D31E8; one verb, no alias.
HasPlayerFlag is a tri-state (null = the local PublicWeenieDesc has not
arrived). The existing arena gates compare `== false` because they reject on a
known-FALSE flag; retail's DoPKLite gates the other way, rejecting on
known-TRUE. So this case compares `== true` on either bit: an indeterminate
description sends rather than blocks, which matches retail trusting the server
instead of inventing a client-side suppression rule.
Landed as its own commit because it is retail-faithful on its own merits, but
the motivation is C4 route 2: ACE advances SequenceType.ObjectForcePosition in
exactly two places, and the only reachable one is Player.HandleActionEnterPkLite's
entry-collision bump (allow_pkl_bump, default on). Every admin teleport advances
ObjectTeleport instead, so @teleto-style displacement exercises route 3, not
route 2. Without this command route 2 has no connected acceptance gate at all.
Gates: complete Release solution 10,867 passed / 4 skipped / 0 failed
(9966b531 baseline 10,858/4/0; +9 = the 9 tests added). Coverage includes both
known-true rejections, the known-false success case, the tri-state unknown
case, the 12-byte wire envelope, and @pklite resolving as ClientHandled rather
than falling through to the server-text path.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A local-player ForcePosition had TWO independent writers for one accepted
packet: LocalForcePositionTransaction snapped the physics body
(PlayerMovementController.BlipPosition, a raw SnapToCell with no collision
resolve), while LiveEntityNetworkUpdateController's generic tail separately
wrote position/cell/rotation to the render WorldEntity from the raw wire and
rebucketed it. Two stores, one packet — the divergence class 670f307c fixed on
the remote path. The outbound AutonomousPosition ack also fired BEFORE any
canonical commit existed: we told ACE "got it, I'm here" before deciding where
"here" was, and the trailing isCurrent() could only suppress the continuation,
never recall the packet.
RuntimeAcceptedPositionDriveController is now the one Runtime-owned seam. Both
hosts call the identical TryExecuteAcceptedLocalPosition; App and headless
project the committed result through the existing placement projection sink
(LiveEntityRuntime.TryApplyRuntimePlacementPlace already performed the same
four writes, from committed state rather than a wire guess).
Retail: SmartBox::HandleReceivedPosition @0x00453FD0's FORCE_POSITION branch is
get_heading -> Frame::set_heading -> SmartBox::BlipPlayer @0x00453940 -> stamp
POSITION_TS -> SendPositionEvent @0x00454091 -> return @0x0045409D. BlipPlayer
is CPhysicsObj::SetPositionSimple @0x005162B0 with flags 0x1012
(Teleport|Slide|SendPositionEvent) — a real collision-resolving SetPosition,
not a snap. The pinned classifier already encoded this exactly.
Named behaviour changes:
* The ack is now an OUTPUT of the committed route, fired strictly after the
canonical commit and exactly once per accepted force packet.
* The ForcePosition route no longer re-arms the constraint leash. The force
branch returns at 0x0045409D, ahead of all three ConstrainTo sites
(0x00454272, 0x0045418A, 0x004541EC); the old re-arm cited retail's "Player,
normal" branch, which BlipPlayer is not on. The teleport, CommitPreparedPosition
and first-entry callers legitimately still constrain and are untouched.
* A force correction that terminates WITHOUT committing still sends its
position event and is not retried — retail's BlipPlayer discards
SetPositionSimple's SetPositionError return and acks unconditionally.
A single _pending funnel owns the in-flight placement, deciding on the token's
PositionAuthorityVersion against the record's: equal -> clear; advanced with the
newest accepted event still a force -> re-issue, re-classified; advanced to an
ordinary Apply -> clear, since newer server truth owns that pose. This closes a
double-apply/double-ack and a silently-dropped correction that two earlier
iterations of this slice each introduced.
AD-62 records the residual: a ForcePosition our async collision publication
cannot carry to a committed placement is not re-applied. Retail has no park —
its world is fully resident and its placement synchronous — so the state is
unreachable there. AP-131 is NOT retired; its legacy Position caller is route 4.
Deleted: LocalForcePositionTransaction, PlayerMovementController.BlipPosition,
HeadlessSessionWorldProjection.BlipLocalPlayer.
Gates: complete Release solution 10,858 passed / 4 skipped / 0 failed (baseline
10,844/4/0). Two independent Opus reviews (retail-conformance and
architecture/adversarial) PASS on the final diff after three FAIL rounds; every
intermediate state was fully green, so the suite caught none of the four real
defects. Connected acceptance is NOT run: nothing a user can do makes ACE emit
a ForcePosition without retail's @pklite, which acdream does not implement — see
docs/research/2026-08-03-c4-route-2-visual-gate.md.
Known gap, recorded not claimed: the plan's acceptance item 2 is unmet. The App
double-write check is a source pin, and "the committed projection moves the
render entity" is uncovered at any layer (#292). Filed alongside: #286-#291,
#293-#296.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Self-contained continuation prompt for the placement cutover. Records the
worktree/branch/HEAD (and that main is still at c7d5fc14 with these commits
unmerged, per the user's direction to work in the worktree), the read-first
list, binding rules, the 10,844/4/0 baseline to measure against, the 10
commits landed on this branch, and the work order: route 2 from its pinned
contract, route 3 with #280 beside it, routes 4-7 folding in #276/#277, C5
closeout, then AP-22 and AD-10.
Carries the two things a fresh session would otherwise have to rediscover:
route 2 is a seam-building slice rather than a wiring job (the accepted-Position
classifier's only production consumer is route 1's Create continuation), and
the complete-suite-before-every-commit gate that the #281-#284 regressions
bypassed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Scoping complete; implementation not started.
Key finding that changes the slice's shape: ClassifyAcceptedPosition already
produces the retail-exact ForcePosition route, but its ONLY production consumer
is RuntimeInitialCreateContinuationExecutor:1948 - route 1's Create
continuation. For an already-live local player receiving a Position there is no
Runtime consumer at all; LiveEntityNetworkUpdateController.OnPosition does the
work in App. Route 2 therefore has to build the accepted-Position execution
seam and then cut App over, rather than wire up an existing one.
The contract records both duplicate authorities with exact file:line, the
retail evidence (SmartBox::HandleReceivedPosition @0x00453FD0 - the
FORCE_POSITION early return preceding unset_parent and the !HasAnims-gated
SetPlacementFrame), the seven contract points, acceptance including the
complete-suite gate, and three implementer risk notes.
Called out for the implementer: the outbound AutonomousPosition ack currently
fires BEFORE any canonical commit, and its trailing isCurrent() only suppresses
the continuation - the ack has already gone out. Moving to retail's
SendPositionImmediately (an output of the executed route) fixes that by
construction, and is a real behaviour change that must be named in the commit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
S1 (#284), S2 (#282), and S3 (#283) are all landed and user-accepted. S3 is
recorded as measured-unreachable rather than restructured, so the plan's
"prove or disprove before moving ownership" step is what actually decided the
outcome.
Final complete Release solution: 10,844 passed / 4 skipped / 0 failed.
Next: the original campaign order, starting at C4 route 2.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes#283 (plan S3) - as UNREACHABLE, not by restructuring ownership.
acdream has two owners that convert a landblock-local network origin into the
streamed world frame: LiveWorldOriginState for presentation/streaming, and
RuntimePhysicsState.TryGetWorldFrameOffset for placement. They rebase on
different edges - Runtime the instant an accepted Position carries
TeleportAdvanced, App only once StreamingOriginRecenterCoordinator observes
old-window retirement completion, many frames later. A one-landblock
disagreement places an entity 192 m from the geometry around it: the same
failure family as the zero-offset bug 670f307c fixed, with a wrong origin
instead of a missing one.
The plan's first step was to prove or disprove reachability BEFORE moving
ownership, because a restructure on a hypothesis is churn. The probe added in
898ff18b answered it: a connected Release session recorded ZERO disagreements
across 11 completed reveals and six destination landblocks (0x0904, 0x1134,
0x3032, 0x8763, 0xA9B4, 0xF682) spanning roughly 45 km. A gap of even one
frame would have printed an offset in the tens of thousands of metres.
Cause of the safety: BeginOriginRecenter detaches EVERY resident landblock
before the new origin is adopted, so the two rebases are serialized and no
conversion can observe the gap. Ownership is therefore left exactly as it is.
What lands instead is the invariant that keeps it true.
LiveWorldOriginState.EnsureAgreesWithRuntimeFrame is checked at the
landblock->world conversion and is terminal on disagreement, converting a
silent 192 m-multiple misplacement into a loud failure with the offset in
metres and the landblock being projected. Six focused tests pin it, including
the cross-world portal case (0x09 -> 0xF6 = 45,504 m). Disagreement can no
longer reach the probe, so ACDREAM_PROBE_WORLD_FRAME now emits a verbose
per-conversion agreement trace - useful when a placement looks displaced for
some reason OTHER than a frame disagreement.
Complete Release solution: 10,844 passed / 4 skipped / 0 failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
#283 step 1: prove or disprove reachability before restructuring ownership.
Runtime rebases its world frame the instant an accepted Position carries
TeleportAdvanced (RuntimePhysicsState.ObserveLocalWorldFrame). App's
LiveWorldOriginState rebases only once StreamingOriginRecenterCoordinator
.Advance observes IsOriginRecenterRetirementComplete - many frames later,
after the old window has fully retired. Between those two edges the owners can
disagree by the source-to-destination landblock delta, and anything converted
in the gap lands a multiple of 192 m from the geometry App is building. Same
failure family as the zero-offset bug 670f307c fixed, with a wrong origin
rather than a missing one.
Reasoning has already closed most of the window: the recenter detaches EVERY
resident landblock before adopting the new origin, so old-origin collision is
retired first. What remains is the narrow gap between Runtime's flip and App's
BeginOriginRecenter, while old-origin geometry is still resident. Whether that
is ever actually hit is an empirical question, and the campaign rule is that a
restructure needs evidence, not a hypothesis.
ACDREAM_PROBE_WORLD_FRAME=1 emits one [world-frame] line per DISAGREEMENT at
DatLiveEntityProjectionMaterializer's landblock->world conversion - the exact
App-side counterpart of Runtime's TryGetWorldFrameOffset, and the site that
already holds both owners, so no new dependency is introduced. Silence across
a portal run is the evidence that #283 is unreachable and can close as a
permanent invariant instead of an ownership move.
Measurement only: the probe never gates placement, and the flag lives in
PhysicsDiagnostics with the rest of the ACDREAM_PROBE_* family per the
diagnostic-owner rule rather than as a scattered env read.
Complete Release solution: 10,836 passed / 4 skipped / 0 failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Connected Release session against the local ACE with the retail UI
(ACDREAM_RETAIL_UI=1). User verdict on the S1/S2 gate: works fine - effects
stay attached to moving entities across cell boundaries, and lit statics are
unchanged (the deliberately-preserved case).
The session log corroborates it: 9 completed world reveals including portals,
58 reveal events all failures=0, zero unhandled exceptions, zero parked
placements, zero firings of #284's new terminal world-frame invariant, and a
graceful exit.
#282 and #284 are closed. #283 (Runtime's world frame and App's render origin
rebasing at different moments during a teleport) remains open and is
deliberately sequenced immediately before C4 route 3, which shares its portal
code; its first step proves or disproves reachability before anything is
restructured.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fixes#282 (plan S2). Adds register row AP-133.
Retail gives a CPhysicsObj exactly ONE cell: ShouldDrawParticles @0x0050fe60
reads this->cell and calls IsInView on it, and set_cell_id @0x0050f4f0 /
change_cell @0x00513390 are the only things that move it. acdream splits that
into ParentCellId (render parent, deliberately null for outdoor dat stabs) and
EffectCellId (the authored landcell those parentless stabs still need) - an
adaptation, now recorded as AP-133.
WorldEntity.EffectCellId documents itself as the stab field, with live and
interior entities using ParentCellId. f24532ad began writing it for live
entities too. Because EntityEffectPoseRegistry resolved EffectCellId FIRST,
that write won - and the audit shows only 3 of 14 cell writers maintain it.
The other 11 do not, including the hottest paths: RemotePhysicsUpdater:239,294
and LiveEntityOrdinaryPhysicsUpdater:107 write ParentCellId every physics tick
from the snapshot, and LocalPlayerProjectionController:79 writes the local
player's cell every frame.
So a moving entity updated its cell constantly while EffectCellId stayed
frozen at whatever cell it materialized in. Its particles and lights kept
being tested against that stale cell and failed IsInView the moment it crossed
a boundary - effects vanishing on a monster that is plainly visible, or
drawing through a wall from a room the viewer cannot see.
The consumers had also drifted into disagreeing: EntityEffectPoseRegistry
preferred EffectCellId while WbDrawDispatcher.TryGetEntityCell and the remote
spawn seed preferred ParentCellId - two answers to "which cell is this in".
- WorldEntity.VisibilityCellId (ParentCellId ?? EffectCellId) is the single
accessor; all five consumer sites resolve through it, so the precedence
cannot drift apart again.
- LiveEntityRuntime's three live-entity EffectCellId writes are removed,
restoring the field to its documented purpose. Its real writers -
LandblockLoader:80,97 and LandblockBuildFactory:408 - are untouched, and the
parentless-stab path is pinned by a new test.
- f24532ad's actual fix is preserved: RebucketLiveEntity still installs the
committed cell, just on the one field live entities use.
LiveEntityLightControllerTests.Refresh_FollowsCurrentTopLevelRootAndCell is
back to moving the entity by ParentCellId alone - its original pre-f24532ad
form - and passes. CanonicalOnlyRebucket_DoesNotOverwriteAuthoritativeFullCell
had its two EffectCellId assertions (added by f24532ad, encoding the defect)
replaced with the corrected contract: ParentCellId set, EffectCellId null,
VisibilityCellId resolving - a stronger assertion, not a relaxed one.
Complete Release solution: 10,836 passed / 4 skipped / 0 failed.
User visual check still outstanding: a monster with an active spell effect
crossing a cell boundary, and a lit static object, indoors and outdoors.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fixes#284 (plan S1).
A first-entry placement that could not be prepared returned
RetrySetupUnavailable and was re-Advanced every pump forever. Nothing counted
it, nothing named its cause, and nothing distinguished "waiting for something
that will arrive" from "waiting for something that never can". That is why
#281's 43 test failures presented as four unrelated symptoms across App and
Runtime instead of one cause, and why a stuck entity in the live client simply
never appears with no log line to follow.
Worse, the two causes were conflated: 670f307c's missing-world-frame park
reported itself as RetrySetupUnavailable, sending anyone diagnosing it to the
prepared-asset pipeline rather than to the absent local-player Create that
actually publishes the frame.
- RetryWorldFrameUnavailable splits the two causes. Call sites now ask
IsRetryable() instead of comparing against one reason, so a future retry
reason cannot be silently reclassified as a hard rejection - the exact way
this class of bug hides.
- The operation retains its RuntimeSetPositionParkReason, and
RuntimeSetPositionOwnershipSnapshot reports parked work by cause
(ParkedAwaitingSetupCollisionCount / ParkedAwaitingWorldFrameCount /
ParkedPlacementCount), so parked placements appear wherever ledgers are
already asserted.
- ObserveLocalPlayerCreate records the accepted local-player Create even when
it carries no landblock - precisely the case where no frame is ever
published - and ThrowIfWorldFrameUnreachable makes that contradiction
terminal. Waiting is legitimate only while that Create is outstanding; after
it, no later pump can supply the frame. Same shape as 01f4791e, which made a
violated receipt-ledger invariant terminal rather than resumable.
This is observability plus fail-fast. There is no timeout, no retry cap, and
no grace period anywhere in it; retryable work still retries exactly as before
and no placement behaviour changed.
The parked counts are deliberately NOT folded into IsConverged: #277 documents
a far Create legitimately parking for a whole session, so a parked entry at
teardown is not automatically a defect. Wiring them into the connected gates
is carried with #277's service-window conversion, where "legitimately parked"
becomes definable.
Runtime 1,012/1,012. Complete Release solution: 10,834 passed / 4 skipped /
0 failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three defects introduced by the 2026-08-02/03 stabilization batch, all found
while reconciling #281's 43 test failures. Each is an instance of the weakness
the placement campaign exists to remove - two owners of one fact with no single
writer keeping them agreed - so they are cleared before C4 stacks six more
placement routes on top of them.
#282: WorldEntity.EffectCellId documents itself as existing only for outdoor
dat stabs, whose null render parent still needs retail's outdoor landcell for
CObjCell::IsInView gating; live/interior entities were explicitly meant to use
ParentCellId. f24532ad began populating it for live entities, and because
EntityEffectPoseRegistry.UpdateRoot resolves EffectCellId ?? ParentCellId it
now wins - while 12+ sites still write ParentCellId alone. Retail carries one
cell per object (CPhysicsObj::set_cell_id @0x0050f4f0, change_cell @0x00513390,
ShouldDrawParticles @0x0050fe60).
#283: 670f307c gave Runtime a world frame that rebases on the accepted teleport
Position, while App's LiveWorldOriginState rebases only after old-window
retirement completes. Between those edges the two disagree by the landblock
delta. Not yet proven reachable; the plan proves or disproves it before
restructuring anything.
#284: a placement that cannot resolve returns RetrySetupUnavailable forever
with nothing counting it or naming its reason. The fix is observability plus
fail-fast on contradictory states, never a retry cap or timeout.
Plan sequences S1 (#284) first so the other two are observable rather than
archaeological, then S2 (#282), then S3 (#283) immediately before C4 route 3,
which shares its portal code. Also records the gating change that would have
caught all of this: the complete Release suite must be green before every
commit, not a focused subset.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
f24532ad changed two presentation contracts that these fixtures still
expressed in their pre-change shape. Both failures date exactly to that
commit; they are independent of the world-frame family fixed in 6dcb94ac.
LiveEntityLightControllerTests.Refresh_FollowsCurrentTopLevelRootAndCell moved
the entity by writing ParentCellId alone. f24532ad now populates EffectCellId
at materialization and keeps it synchronized on canonical rebuckets
(LiveEntityRuntime.RebucketLiveEntity:845-856), because retail's
CPhysicsObj::set_cell changes the one CObjCell that ShouldDrawParticles reads.
EntityEffectPoseRegistry.UpdateRoot:163 resolves EffectCellId ?? ParentCellId,
so a production cell move writes both together and the old single-field move
left effects and lights on the stale materialization cell.
LiveEntityAnimationSchedulerTests.RetainedProjectileWithRemote_WhenMissileClears_TransfersMovementToRemoteOnce
seeded its shared remote body by assigning Position directly. Projectile
classification now validates and adopts the canonical body's own cell frame
(ProjectileController:184-190 - body.CellPosition.ObjCellId /
.Frame.Origin) rather than deriving it from the sidecar's FullCellId and the
streaming center, since a residence-managed Create legitimately still reports
FullCellId 0. A Runtime-committed body always carries its (cell, local) frame,
so the fixture now seeds it through the same SnapToCell placement API; leaving
it cell-less was correctly refused.
Both fixtures keep their original assertions - only the modelled world state
moved to match what production now commits.
Complete Release solution: 10,831 passed / 4 skipped / 0 failed
(App 4,048/3, Bake 15, Cli 4, Content 124, Core.Net 762, Core 4,247/1,
Headless 79, Runtime 1,009, UI 543).
Closes#281.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
670f307c made remote first-entry placement resolve its landblock-local
CreateObject origin through Runtime's world frame
(RuntimeSetPositionState.PrepareMover:1526-1544) and return
RetrySetupUnavailable until that frame exists. Only the accepted local-player
Create publishes it (RuntimeEntityObjectLifetime.RegisterEntityCore:558-570 ->
RuntimePhysicsState.ObserveLocalWorldFrame).
Fixtures that drive remote conductors in a world with no local player - a
state production never occupies, since the player's own Create always precedes
broadcast Creates - therefore parked forever on RetrySetupUnavailable. Their
initial-create residences never retired, which cascaded into rejected
appearance updates, missing canonical bodies, unconverged ownership ledgers,
and a GameRuntime teardown that could not complete stage 10.
The measured blast radius was far larger than the handoff recorded. It claimed
"six selected fixture failures"; a baseline run found 43. The App suite was
fully green at 01f4791e and 670f307c broke 28 tests at once; the Runtime suite
lost 13, twelve of them in RuntimeRemoteFirstEntryStateTests - the exact
conductor that commit gated. Both commits were verified on focused runs only.
The production gate is correct, so nothing here weakens it. It matches App's
own coordinate owner: LiveWorldOriginState is initialized once from the local
player's spawn (LiveEntityHydrationPorts.cs:226) and rebased only by
StreamingOriginRecenterCoordinator.Advance at a teleport boundary - exactly
ObserveLocalWorldFrame's semantics. Every fixture is repaired by supplying the
missing precondition beside the resident landblock it already models, and not
one expected value or assertion was changed.
The mechanism shipped with zero tests. RuntimeWorldFrameTests now pins its
contract: the local player publishes the frame, remotes never do, neighbouring
landblocks convert at 192 m per step, ordinary movement across a landblock
boundary must NOT rebase it, an accepted teleport must, and a zero cell id
neither publishes nor resolves. That "no rebase on ordinary movement" rule is
load-bearing - if it and LiveWorldOriginState ever disagree, remote objects
are placed a multiple of 192 m from where the world is streamed.
Runtime 1,009/1,009; App 4,048 passed / 3 skipped.
Refs #281.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ACE intentionally creates the local player Hidden and releases that materialization state on LoginComplete. Sending LoginComplete from raw F746 receipt raced canonical placement and left the login haze visible. Route one one-shot completion callback from Runtime's local first-entry terminal edge to graphical and prepared headless hosts; retain a guarded accepted-Create edge only for content-less headless sessions. Focused Runtime login tests, all 79 Headless tests, the connected user gate, and the Release build pass.
C3c created graphical effect, projectile, and static-animation sidecars before Runtime finished the entity's first SetPosition. One-shot F754/F755 packets could be discarded, projectiles could adopt a cell-less body, and animated statics could compete for body ownership. Keep effects behind an exact-incarnation presentation barrier, retry projectile/static binding on the committed visibility edge, and keep effect cells synchronized with canonical rebuckets. User verified spell, recall, arrow, projectile, portal, and static presentation; 90 focused App tests and the Release build pass.
Runtime GetObjectA lookup became intentionally non-constructing, so static doors and corpses entered MoveToObject without a physics host and their target snapshot timed out at the origin. Ensure the canonical minimal host exists before routing the server move.
Runtime first-entry also grounds the local player before graphical PartArray attachment. That could leave an unmatched startup CMotionInterp node ahead of all later use and cast motion. Drain matched PartArray entries first, then retire only the impossible pre-attach suffix at the presentation attach boundary.
Add focused regressions for static-target host materialization and attach-order reconciliation. User verified near and distant object use in the connected client; focused App tests pass 3/3.
CreateObject positions are landblock-local, but Runtime first-entry previously submitted remotes with a zero world offset. Runtime now owns the accepted local-player world-frame center and converts remote placements before SetPosition. The local physics host also publishes body.Position rather than CellPosition's landblock-local origin, so TargetManager no longer directs monsters toward a phantom player position. User gate: monster/static placement, chase, and attacks accepted outside Tusker Barracks.
Root cause: pending-only live projection buckets were misclassified as landblock presentation owners during origin recentering. That manufactured a second full cleanup receipt for a generation whose first receipt was still advancing; the duplicate guard threw and the broad retry path replayed the already-committed detach 243 times.
Keep pending live projections through the spatial identity map without issuing another receipt, and fail fast when a receipt-ledger invariant occurs after detachment. Evidence: docs/research/2026-08-02-collision-throughput-handoff/p1-retirement-receipt-loop.md. Release suite, lifecycle gate, and nine-stop soak pass.
Late-arriving adversarial-review artifact: deletions confirmed clean
(no surviving consumers, no post-Revoke dereference), one dead orphan
(CollisionWorldStateSlot.TransferTo), two stale test names, and the
stale-docs catalog for whoever lands the collision work.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Publication-throughput rework per the D2 design (docs/research/
2026-08-02-collision-throughput-handoff/design-note.md): O1 per-prefix
installed-key ledgers replacing the seal's full-map scans; O2 per-
landblock delta commit (LandblockReplacementApplyCursor against the
active root) replacing whole-world TransferTo; O3 empty staging root,
commit-time reflood (CObjCell::init_objects 0x0052B420 ->
recalc_cross_cells 0x00515A30), journal/peer-rebase machinery deleted
(~1,900 lines net).
Automated gates green: Runtime 999, Core physics 2,135, App 4,039/3,
Headless 79, complete solution 10,812/0/4; lifecycle gate PASS
(connected-world-gate-20260802-193029). Soak 194423: publication-side
acceptance fully met (37 -> 4 failures, all convergence dims zero,
loadedLandblocks baseline-identical, waitCue 6/9 -> 1/9).
COMMITTED AS WIP ON USER DIRECTION - NOT ACCEPTED. The user feel-test
FAILED on this tree: monsters still pop into existence at close range,
monsters spawned mid-air far ahead, static placements visibly wrong,
plus 243x "Landblock already has a full retirement receipt"
InvalidOperationException catch-retry loop during origin recenter
(launch-feeltest-oclone.log). The 4 remaining soak failures
(pendingLandblockRetirements 131/122 at the Caul->Sawato stops) and the
implementer's "exposed pre-existing" classification are under
re-judgment against that loop. Dual reviews were dispatched and then
stopped mid-flight on user direction; NO review has passed this commit.
Full problem inventory + next-agent instructions:
docs/research/2026-08-02-collision-throughput-handoff/.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
User session observations: late monster pop-in, extended/stuck portal
space, and portal-exit character pop-in are the 6b28ff99 publication-
throughput regression made visible by C3c retail-correct wait-for-
collision placement (next slice). Intermittent spell particle loss filed
as #279: one-shot scripts arriving in the suppressed-until-receipt
window need retail pending-script deferral to presentation binding.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
C3c COMPLETE at 529e0e9d in the placement-cutover plan (five fix slices,
R1 dual-review round, final gates). New closeout research note. ISSUES
#276 (settle-CellId discard), #277 (route-1 far-Create radius bound),
#278 (user-session triage bundle). Register AD-60/AD-61 numeric order.
The next slice before C5 is the 6b28ff99 O(changed) collision clone
(soak convergence); C4 resumes after.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Campaign P remaining-physics-divergence, placement cutover slice C3c
(docs/plans/2026-08-02-placement-cutover.md). Both production hosts now
register every initial Create through the residence + continuation-
executor + first-entry-conductor machinery (C0-C3b):
- Graphical (route 1): RegisterEntityWithInitialResidence at Create; the
shared RuntimeFirstEntryDriveController pumps both conductors from the
placement-receipt flow; MaterializeProjection and RebucketLiveEntity
are presentation-only while a residence is ACTIVE (ExecutorCompleted is
the presentation-binding receipt); post-residence entities take the
full legacy path including the prepare_to_enter_world clock edges.
PlayerModeController attaches presentation to the Runtime-published
controller; its legacy resolve/step-heights/host-construction path is
deleted; presentation-only rollback (retail has no entry-flow rollback).
- Headless (route 8): OnSpawned registers with residence when a drive
exists; content-less sessions keep the pre-flip direct registration;
SynchronizeLocalPlayer/CreateController/ApplySetupStepHeights deleted;
prepared-collision read failure is a typed AwaitingCollisionSource
retry; far remotes outside the service window complete celless.
- RuntimeLocalPlayerMovementState.Controller setter sealed internal; all
controller mutation flows through the publication lifecycle.
Fix slices landed within this cutover, each dual-gated:
- F1: live movement-stat/server-physics application routed through the
Runtime ownership seam (post-logout ingest crash on the retired
controller eliminated; RuntimeMovementSkillProjection deleted).
- F2: login activation wedge - collision-admission prefix gate factored
out of the seal (reentrant-commit RejectedAuthority), rearm generation
identity corrected, PlayerModeAutoEntry requires the Runtime-published
controller (world reveal can no longer seal unmaterialized).
- F3: landblock-prefix 0-sentinel replaced by explicit absent-id guards;
map-corner landblocks (grid row/col 0) fully legal through admission,
park/rearm/retire, quiescence, and outdoor shadow seeds.
- F5: local-player first-entry ground contact seeded by the shared
SpawnPlacementSettler (moved App->Core) at FinalizeActivation - the
retail first-gravity-frame touch (enter_world 0x00516170 carries no
seed); the legacy unconditional force-seed is overwritten by a real
floor-found contact; airborne spawns stay airborne; outbound contact
bit verified end-to-end. Fixes the standing-cast 'You can't do that
while in the air!' rejections.
- R1 (dual-review round): login constraint leash armed at the committed
placement (HandleReceivedPosition 0x00453FD0 analog); register rows
AD-61 (settle-timing compression now covering the local player) and
AD-42 (repointed off the deleted resolve split) in this commit;
residence-conversion owner API; wire-landblock guards; drive-pending
ledger in IsConverged; route attach/detach latch; executor-drain drift
model documented + source-pinned.
Gates: Runtime 1,003, App 4,039/3 skips, Headless 79, complete solution
10,816/0 failed/4 skips (Release, -m:1); connected lifecycle/reconnect
gate PASS (logs/connected-world-gate-20260802-175401; graceful exits,
world-visible, zero airborne rejections). The nine-stop soak remains red
for the pre-existing 6b28ff99 whole-world collision-clone throughput
regression (attributed with evidence; scheduled as its own slice before
C5). Dual Opus reviews (retail-conformance + adversarial): delta PASS.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
C3b landed at 0934a121 with dual review PASS. The plan records the
remote-entry mechanism and its verified retail anchors; the float-gates
doc gains the port note pinning the NaN dispositions (friction's
sanctioned skip; elasticity and translucency routed exactly as the
binary; ACE's elasticity NaN divergence recorded). Every dormant C3
prerequisite is now complete — C3c, the host flip with the connected
gates, is the sole remaining piece of C3.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Cutover slice C3b: residence-route remote/creature/projectile Creates now
get their canonical PhysicsBody at Create time — retail's order, closing
the C3 flip's Finding C (production builds bodies at first motion; retail
builds them in ACCObjectMaint::CreateObject). RuntimeRemoteBodyDescription
walks set_description 0x00514F40 exactly: the motion-table gate (zero id
PASSES — verified at 0051871f/005127ca), the frame-vs-movement branch
keyed on retail's movement_buffer != 0 (an empty-buffer movement payload
takes the PLACEMENT branch and writes no autonomy — the wire-shape defect
the retail review caught), set_state, the byte-certain friction gate
(inclusive [0,1]; NaN deliberately skipped per the gates doc's sanctioned
deviation), the set_elasticity clamp with retail's unordered-to-zero NaN
routing (ACE diverges to 0.1 on that edge), the translucency gate
(!= 0.0f, original always recorded), velocity via setter, omega raw, and
ctor-defaults for absent wire fields (0.95f/0.05f/0 — the fresh-desc-per-
message flow verified at both UnPack call sites). InWorld stays false
until submission, the enter_world analog.
RuntimeRemoteFirstEntryState sequences mover-prep -> body construction ->
placement -> acknowledgement -> Execute with every C3a hardening
inherited: exactly-once stages, the shared acknowledge-stage
discriminator (extracted to RuntimeFirstEntryAcknowledgement, one body
for both conductors), typed Contention against in-flight remote-motion
binds, never-clobber body binding through the canonical writer (foreign
body fails closed — provably safe coexistence with today's
build-at-first-motion path in both directions), automatic convergence
through the retirement fan-out, and the construction receipt riding the
terminal Advance. Dormant: no production caller; C3c wires both hosts.
Reviewed: retail-conformance PASS (the construction order, both gate
boundary/NaN semantics, the motion-table and autonomy verdicts all
re-derived from the pseudo-C) + architecture/adversarial PASS after one
fix round. Runtime 982/982; complete Release solution 10,777 passed / 4
intentional skips.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
C3a landed at 960373df with dual review PASS. The plan records the
conductor's five-stage sequence (verified step-for-step against retail's
entry order, with the mover-shapes-first correction the tested
preconditions forced), the convergence/wiring closures, and the two
carried findings C3c must honor. Next: C3b remote body construction at
Create, whose float-gate oracle is committed at 874d94bf.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Cutover slice C3a: the resumable transaction that dissolves the C3
flip's circularity finding. RuntimeLocalPlayerFirstEntryState drives the
local player's complete entry in retail's own order — authored-mover
preparation (the makeObject/set_description shape analog, via a pure
no-submit extraction TryPrepareAuthoredMover), the publication chain's
off-canonical Prepare + atomic body Commit against the residence's exact
placement token, the Evaluate/CommitActivation enter-world analog, the
Place-receipt acknowledgement as that act's virtualized completion, and
only then the executor's FIFO drain (retail: enter_world at 93824
strictly precedes ProcessObjectNetBlobs at 93831). Five stages, eight
typed statuses, exactly-once per stage under retry, no second token
copies, and an acknowledge-stage discriminator that separates
not-yet-FIFO-head (retryable) from authority-moved (typed abandonment) —
a mid-flight delete can no longer strand a retry-forever entry.
The residence retirement notification becomes an ordered multicast
(snapshot-iterated per the event-stream precedent), the lifetime
constructs the conductor with a late-bind Publication seam (transactional
unbound failure — no mutation before the throw), deletion/reset converge
the conductor automatically through the same choke points as the
executor, and its active count is in the ownership snapshot and
IsConverged. Dormant: no production Advance caller; GameRuntime binding
is C3c's first act.
Reviewed: retail-conformance PASS (the stage order verified
step-for-step against retail's entry sequence; the live-controller-on-
abandonment invariant proven structurally enforced and retail-correct —
retail has no entry-flow rollback) + architecture/adversarial PASS after
one fix round (acknowledge-stage authority discrimination; the wiring
fold; two prescribed pre-C3c hardenings). Runtime 948/948; complete
Release solution green across all nine projects.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
C3b's blocking retail question, resolved byte-certain from the
PDB-paired v11.4186 binary: CPhysicsObj::set_description applies the
desc's friction only when 0.0 <= friction <= 1.0 (outer JNP-on-parity
gate vs 0.0 double at .rdata 0x00794610; inner <= 1.0 vs 0x3FF0... at
0x007928c0), and applies live translucency + the CPartArray propagation
only when translucency != 0.0f (FCOMP m32 vs 0.0f at 0x007c6a80;
translucencyOriginal is written unconditionally before the gate). Every
FLD/FCOM operand address read from .rdata and every FNSTSW/TEST/Jcc
decoded by hand; ACE PhysicsObj.cs:3557-3568 independently reproduces
all three predicates as the cross-check. Unblocks the C3b remote
body-construction port.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The first C3 implementation pass landed C3-1 (fe02c4f5) and correctly
stopped on two structural gaps no planning document captured: the local
player's first-entry circularity (the residence opens its placement at
Create, submission needs a body, and only the zero-caller publication
chain can attach one — resolvable by the campaign handoff's own route-1
order, but no driveable state machine exists) and the absence of any
remote-creature body construction at Create time (retail builds physics
in ACCObjectMaint::CreateObject; ours arrive with first motion). The
plan now records the C3a (first-entry conductor, dormant) / C3b
(retail-anchored remote body construction at Create, dormant — its
contract must first resolve set_description's three FPU-elided
friction/translucency gates from the PDB-paired binary) / C3c (the
actual host flips + connected gates) decomposition.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Cutover slice C3-1 (the C3 flip's Runtime prerequisite, landed separately
after the flip itself was halted with structural findings — see the plan's
C3a/b/c decomposition). Hosts can now read the executor-completion facts
they must bind at cutover through one public, generation-gated channel
accessor: RuntimePlacementProjectionChannel.TryGetInitialCreateCompletion
returns RuntimeInitialCreatePlacementCompletion — the teleport-hook phase,
resident cell, replay outcomes, and per-Position route facts (disposition,
constrain phase, hook phase, stop-interpolation/zero-velocity/preserve-
heading/send-position flags) via public 1:1 mirror enums of the internal
classifier vocabulary. The projection is built once at completion, cached
in the same reaped entry as the internal receipt (identical acknowledge/
discard/clear lifecycle, ledger-covered), and read allocation-free.
Mirror maps enumerate every value explicitly with throwing catch-alls,
guarded by a sabotage-verified arity/round-trip reflection test. Doc
comments pin the two consumption rules: unparent/placement-frame are
already applied to the canonical snapshot (hosts must not re-apply), and
array order — not Sequence — is the authoritative Position-fact ordering.
Reviewed: architecture PASS + retail-conformance PASS (mirrors verified
member-for-member against the retail phase semantics; the route-fact
selection confirmed to cover exactly the host-bindable deferrals).
Runtime 932/932; complete Release solution 10,727 passed / 4 skips.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
C2 landed at 63c601ff with dual review PASS after two fix rounds. The
plan records the halved allocation result and tightened gate, the
class-wide token-based staleness rework the pooling forced, the
documented residual floor (Core-side ~520 B/op deferred to the C3
activation gate as a possible C2b), and the two review maintenance
notes. Next slice: C3, the spawn-frequency host cutover of routes 1+8.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Cutover slice C2: the dormant placement path's per-operation cost was the
recorded activation blocker for routing frame-frequency traffic through
the canonical SetPosition owner (1,880 B/op measured at 4B2, cap 2,048).
Root-cause removal, not a raised cap: the per-operation envelope is now
pooled (bounded 64, reset-at-rent, InPool double-retire guard, cleared on
session reset/dispose and surfaced as a diagnostic ownership count), the
two engine-callback closures became one cached delegate over an explicit
context stack, and the pending-projection head read no longer boxes the
sorted enumerator. Measured 2,032 -> 944 B/op; the regression gate
tightens to 1,536. The residual floor is documented at the gate: ~520 B
inside Core's PhysicsEngine.SetPosition (outside this slice's scope) and
~208 B of sorted-tree node per pending receipt.
Pooling demanded — and received — the full staleness-discipline rework:
every frame holding an operation across a reentrancy point now captures
its never-reissued token and revalidates via fresh lookup
(IsCurrentByToken / token-shaped CancelCore), because a recycled
instance reinstalled at the same key makes every reference-identity
check a tautology. All ~26 sites audited (15 remain reference-based with
per-site no-reentrancy proofs); CommitCanonical's post-callback reads
are hoisted stack locals mirroring retail's savedTransientState pattern
(handle_all_collisions bits, pseudo-C 283952), its bookkeeping writes
are token-gated, and the settle path stays deliberately identity-
agnostic because retail's SetPositionInternal runs its physical settle
unconditionally even for displaced operations.
Reviewed: retail-conformance PASS + architecture/adversarial PASS after
two fix rounds (the ground-edge recycle window, the pool's cross-reset
retention, the class-wide tautology, a self-found snapshot-reference
iteration hazard). Runtime 927/927; complete Release solution 10,722
passed / 4 intentional skips; budget test green at the tightened gate.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The C1 body-writer research found the atomic controller/body transaction
already built and tested: RuntimeLocalPlayerPhysicsPublicationState plus
the dormant local-activation family implement the sanctioned
off-canonical-prepare + validated-atomic-commit shape end-to-end, with
zero production callers. The committed writer map records the six
canonical body writers, the two host escape hatches (the public
Controller setter both hosts write directly; App's object-clock facade
bypasses), the headless prepared-collision fragility, and both hosts'
construction divergences. C1 therefore collapses into C3's route-1 flip
— the remaining work is production wiring, not mechanism design — and
C2 (the placement allocation budget) becomes the next slice.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
C0 landed at 67f63e85 with dual review PASS; the plan now records its
delivered seam (acknowledge-only ExecutorCompleted receipts through the
one placement stream, retail-exact live-input derivation, the chained
authored-mover preparation, the cancellation-symmetry hardening) and the
three C3 prerequisites its reviews surfaced: the internal-only completion
receipt surface, the per-Execute distance-freshness deferral, and the
SendAutonomyLevelEvent obligation on any future autonomy-level host
exposure. Next slice: C1, the atomic controller/body publication.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Cutover slice C0 (docs/plans/2026-08-02-placement-cutover.md): the seam
work that lets C3 flip hosts onto a complete receipt stream instead of
growing one mid-cutover. The executor's Released exit now publishes an
acknowledge-only ExecutorCompleted receipt through the one placement
projection stream — registered before observer dispatch, correlated to
the full execution receipt, reaped exactly once on acknowledgement/
discard/session-clear, and counted in the convergence ledger. All three
production placement sinks acknowledge-and-ignore the new kind via early
returns proven behavior-preserving for every existing kind; without them
the first such receipt at cutover would permanently wedge the exact-head
FIFO behind sinks that return false. Provably inert today: the publisher
has no production caller.
Execute's live inputs now derive from Runtime's own owners bound at
GameRuntime construction: UsePositionFromServer is retail's exact
autonomy_level != 2 (CommandInterpreter::UsePositionFromServer
0x006B3B40, startup-only knob), and PlayerDistance uses the live movement
controller's position with a null-safe fallback to the caller struct —
never a fabricated origin. TryPrepareAndSubmitAuthoredPlacement chains
the prepared-collision Setup read through PrepareMover to submission with
zero validation-semantics changes. TryCommitParent and CommitWithdrawal
gain the sibling cancellation flow (residence + ordinary placement
family); TryCommitParent deliberately omits LeaveWorld — retail's
set_parent performs its single gated leave_world (0x00515A90) and a
second would have no counterpart.
Not fully dormant: the two cancellation fixes change Runtime paths
production already calls (today as no-op-adjacent hardening, since
nothing upstream begins a residence yet); everything else is reachable
only by tests. Reviewed: retail-conformance PASS + architecture/
adversarial PASS after one fix round (sink wedge, completion-receipt
lifecycle, null-controller distance). Runtime 921/921; complete Release
solution 10,716 passed / 4 intentional skips.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The continuation executor (5db3de3c) completed the dormant residence
mechanism; the cutover is the campaign leg that makes it production truth.
The committed 8-route inventory maps every duplicate placement authority in
both hosts with exact call chains, confirms the placement-receipt observer
seam is fully built but unattached, and surfaces five pre-cutover gaps the
shipped mechanism cannot yet express (executor-to-channel bridge, atomic
controller/body publication, the 1,880 B/op activation budget, Runtime-side
live-input derivation, the portal-authority adapter). The plan decomposes
the cutover into C0-C5 bisectable slices under the campaign's standing
contract/dual-review/gate discipline, ending at the connected routes and
the user visual matrix that retire AP-1/AD-1.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Synchronize the architecture doc, milestones, roadmap, and ISSUES with the
continuation-executor behavior commit (5db3de3c): the residence system is
now a complete dormant mechanism, both independent reviews PASS, and the
next boundary is the all-host production cutover. The admission handoff
gains its superseded banner; the successor handoff records the executor's
ownership, the retail anchors proven during review (the wire-contact gate,
queue-by-parent-GUID relation replay, HasAnims semantics), the seven new
register rows, exact test totals, the rollback command, and the cutover
checklist. #275 filed for the post-cutover legacy-Position unification.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The admission checkpoint (30012361) sealed accepted updates behind a
pending initial placement; nothing could apply them, so AcknowledgeAdoption
refused any non-empty FIFO and the residence system had no path to
completion. RuntimeInitialCreateContinuationExecutor is that missing
mechanism: a synchronous, retry-idempotent Execute transaction that adopts
the acknowledged initial placement exactly once (consuming the retained
completion so later authored placements for the key can begin), emits the
AfterEnterWorld hook request for the local player, replays deferred
missing-parent raw Creates and queued parent relations by parent GUID
(retail ProcessObjectNetBlobs order: whole-bucket detach, FIFO dispatch,
cancellation-aware restore), and drains the mixed continuation FIFO
strictly by sequence with retail route decisions taken at execution time
via ClassifyAcceptedPosition on live inputs (server-asserted wire contact,
data-driven animation proxy, live distance/options).
Apply bodies are shared with the legacy fused paths through new gate-less
instance seams on InboundPhysicsStateController that keep the one snapshot
store in lockstep; SameIncarnationCreate envelopes apply atomically with
per-stage idempotency and buffered publication after the final stage;
every abandonment path retires the residence through the lifetime choke
point and converges the ownership ledger (executor progress, deferred
buckets, replay windows, placement watches all folded into IsConverged).
Position/placement side effects are exactly-once under retry, external
mutations are detected via a field-masked executor baseline, and
AwaitingContinuationPlacement yields keep the FIFO head retryable.
Production routes are deliberately untouched: graphical and headless
Create still use legacy RegisterEntity, and no host calls Execute. The
cutover is the next checkpoint; AP-1/AD-1 remain open until it lands.
Register rows AD-59/AD-60/AP-130/AP-131/AP-132/TS-62/TS-63 document the
slice's deviations in this commit.
Reviewed: retail-conformance PASS + architecture/adversarial PASS after
five implementation rounds (wire-contact source, snapshot lockstep,
WeenieDescription merge, abandonment convergence, reentrant retirement
windows, acknowledged-completion leak, baseline precision, replay
containment/restore, queue-by-parent-GUID relation deferral all fixed at
root cause). Runtime tests 903/903; complete Release solution 10,696
passed / 4 intentional skips; focused executor gate 161/161.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The instruction files still described ImGui.NET + the OpenGL ImGui
extension as the permanent ACDREAM_DEVTOOLS=1 developer stack and gated
the modern rendering path on GL_ARB_bindless_texture /
GL_ARB_shader_draw_parameters. Campaign V (V11) deleted the OpenGL
backend and the ImGui developer-tools frontend with it; AcDream.App
references only Silk.NET.Vulkan, and ACDREAM_DEVTOOLS=1 now only
selects the optional Vulkan validation/debug-utils extensions
(Program.cs logs exactly this). Update the two stale paragraphs in both
synchronized files; shared content remains byte-identical.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
User-verified: casting fixed (exhaustion-edge gate) and monster attack
animations restored (spawn settle placement + lost-cell retry). Final
session evidence: 14/15 spawn settles grounded; Falling-refusal spam
collapsed 2,954 -> 15 transient pre-settle lines.
Strips the [UM-ACT]/[MT-FAIL]/[SPAWN-PLACE]/[remote-edge] probes, the
MotionInterpreter.DiagnosticGuid plumbing, and the two throwaway probe
tests (motion-table attack sweep, vitae color dump - both findings are
recorded in ISSUES/research). Complete Release suite: 10,030 passed /
5 skips / 0 failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The [SPAWN-PLACE] probe showed placements succeeding with contact=False:
find_placement_pos validates the spot but the sphere sits a few cm above
the floor with no touch. Retail gains spawn contact from the FIRST
GRAVITY FRAME (every CPhysicsObj simulates, falls, touches); our
stationary remotes never run a physics frame. The seed now compresses
that settle: a short downward ResolveWithTransition from the server
position snaps the body onto the floor, and its touch grants the contact
plane + CONTACT/ON_WALKABLE via the verbatim commit. No floor within
reach = stays airborne, exactly like retail's fall.
The retry predicate now watches the CONTACT transient (the flag
contact_allows_move reads) instead of ContactPlaneValid - a DR-tick
writeback can set plane DATA from last-known state without real contact,
which is why the heavy attackers in the retry-session log got exactly
one placement attempt and then 250+ refused Falling dispatches.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The creation-only spawn placement could no-op or fail during the login
flood (cell id not yet hydrated / streaming collision not resident) with
nothing retrying - monsters created in that window stayed
airborne-flagged forever and their action animations remained refused.
Retail's answer to 'object addressed before its cell exists' is the
CObjectMaint lost-cell list (GotoLostCell): park, re-place when the cell
is available. The UM dispatch path now retries SeedRemoteSpawnPlacement
while the body has never been successfully placed (no contact AND no
stored plane); one success ends the retries.
Probes: [SPAWN-PLACE] logs each placement outcome (guid/cell/ok/
contact/walkable); [UM-ACT]/[MT-FAIL] now carry the owning guid via
MotionInterpreter.DiagnosticGuid (probe-identity-attribution lesson)
plus the body's live contact/walkable flags.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The [MT-FAIL] probe caught combat-stance monsters constantly failing to
dispatch 0x40000015 (Falling): their bodies were airborne-flagged while
standing. contact_allows_move (0x00528dd0) requires Contact+OnWalkable
and silently refuses every action animation for an airborne mover - a
spawned-standing monster's swings never played until it first moved.
Retail never has this state: CreateObject spawns run the placement
transition (CPhysicsObj::SetPosition -> SetPositionInternal 0x00515330),
which establishes CONTACT/ON_WALKABLE from the floor at spawn. Our
remote creation seeded a raw position with no placement.
SeedRemoteSpawnPlacement mirrors RemoteTeleportPlacement: engine
placement resolve (Setup-derived cylinder, TS-46) + the verbatim
CommitSetPositionTransition, wired at BOTH RemoteMotion creation sites
(UM-triggered creation - so a first-ever-UM attack animates in the same
packet - and ordinary first-UP creation). Unplaceable results leave the
body airborne exactly like a failed retail placement.
Also adds the [UM-ACT] (wire action items + stamp-gate verdict) and
[MT-FAIL] (refused animation dispatches) probes, riding
ACDREAM_DUMP_MOTION=1, which are what convicted the body state.
Complete Release suite: 10,032 passed / 5 skips / 0 failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Retail calls CPhysicsObj::report_exhaustion from exactly one site -
CommandInterpreter::HandleExhaustion (0x006b3c70), a notification handler
for the stamina-exhaustion EVENT. Campaign P P1 wired it to every
movement-stats application instead (every stamina regen/drain tick), and
each call re-dispatches the current movement state through the animation
sink - truncating any in-flight action animation. The diagnostic session
log shows 490 spurious casting-stance re-queues in one short session:
'sometimes stuck in spell animations' was every stamina tick that
collided with a cast gesture's play window.
The re-apply now fires only when the exhausted state (stamina == 0)
transitions, matching retail's event semantics. Stats still reach
PlayerWeenie immediately via RuntimeMovementSkillProjection.ApplyTo.
Also adds the [remote-edge] probe (rides ACDREAM_DUMP_MOTION=1): one
line per remote HitGround/LeaveGround - each such edge drains the
mover's pending action animations (retail HandleEnterWorld), the
working theory for intermittently missing monster attack swings.
Complete Release suite: 10,026 passed / 5 skips / 0 failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Retail jump landings BOUNCE: the floor touch records both a contact plane
(grounding) AND a collision normal (collided_with_environment), and
handle_all_collisions reflects the unmodified impact velocity off it at
5% elasticity (v += -(v.n)(elasticity+1).n, DEFAULT_ELASTICITY 0.05
@0x007c6a7c). Our transition already recorded both facts; the bounce was
suppressed by the AD-25 adaptation stack in the per-tick commit: a
Velocity.Z<=0 landing gate (needed because the resolver glued ascending
movers to the ground) plus a landing Velocity.Z=0 hand-zero whose stated
purpose was making the reflect a no-op. Downhill glided instead of
bouncing, flat-ground landings had no pop, and uphill jumps flapped
between grounded/airborne against the animation machine.
Three retail mechanisms replace the stack:
- check_contact (0x0050f5b0) seeding in ResolveWithTransition: a body in
CONTACT seeds the transition's contact only while v.contactPlane.N <=
0.0002; moving away seeds the last-known plane alone (get_object_info
0x00511cc0). Ascending jumps therefore run contact-free (ballistic, no
glue) - the gate's reason-for-being is gone. The plane requirement is
strict: Contact-without-plane is unrepresentable in retail.
- SetPositionInternal-shaped commit (0x00515330, byte-read end-to-end,
velocity-sign-FREE): contact purely from the transition's contact
plane, HitGround on the airborne->walkable edge, HandleAllCollisions
with unmodified impact velocity. Whole commit gated on Ok &&
candidateMoved (retail pc:283657 skips SetPositionInternal entirely
when the candidate did not move) - a standing body's contact state is
never re-derived, which is what keeps rest bit-stable (AD-41 updated).
- Byte decodes: gate override state&0x800000=Sledding, zero branch
state&0x20000=Inelastic, reflect strictly dot<0 - our port already had
all three correct.
Settle: real landings (>=0.25 m/s) bounce and decay geometrically;
smaller impacts are consumed by retail's unconditional small-velocity
zero, so standing never micro-bounces. Re-baselines documented in place:
landing-survival pin measures decay post-settle; LiveCompare_Tick0/376
pin the new IsOnGround=false on zero-move ticks (captured true was the
retired seed echo; tick 376's captured body carries an 11.8 m/s grounded
velocity from the deleted get_state_velocity-overwrite era); de-overlap
fixture now carries the plane real grounded bodies always have. New
pins: LandingBounceSeedingTests (ascent no-seed, rest keeps contact,
strict plane, slope 5% reversal + tangential preservation, Sledding
override).
Investigation + implementation record:
docs/research/2026-07-30-landing-bounce-family.md. Complete Release
suite: 10,031 passed / 5 skips / 0 failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
docs/ISSUES.md: #265 and #166 updated with the root cause and fix from
the prior two commits; closure of both pends the user's visual-gate
acceptance. #265 also records the confirmed-separate uphill-bounce
finding (AD-25, byte-exact retail, out of scope). #166 records that the
Campaign P visual-matrix recheck it was waiting on DID happen and found
the glide/bounce still missing even with AD-25/AP-7/AD-55/TS-4 all
landed - that negative result is what triggered the #265 capture bisect
and this fix.
docs/architecture/retail-divergence-register.md: AP-7's retirement note
corrected. The row's original claim ("no horizontal velocity to hammer")
undersold the gap - calc_friction was structurally unreachable with
meaningful data on any grounded path, not just inert on the root-motion
path. No new row filed: this change ports retail's mechanism faithfully
and does not introduce a new deviation.
docs/research/2026-07-30-265-capture-bisect.md: full "as-fixed" addendum
(new section 9) recording the implementation - the fix mechanism, fixture
results (freeze reproduced under the old model, slide+decay proven under
the new one), the downhill-direction derivation for the synthetic decay
case, the two separate mechanisms found while building the Runtime tests
(LeaveGround's edge-timing recompute, AP-77's no-sink fallback), the
uphill-bounce orthogonality proof, and final test totals.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two new PlayerMovementController-level tests, exercising the real
production controller (not just the Core-level composed model in the
prior commit):
- Update_AnimationRootMotion_WalkSpeedUnaffectedByResidualVelocityFix:
ordinary root-motion walking (no fall/collision in flight) advances by
exactly the authored per-tick delta for 30 ticks with BodyVelocity
staying exactly zero throughout - the fix is a complete no-op for the
common case, pinning the L.3c hazard
(claude-memory/project_physics_collision_digest.md's DO-NOT-RETRY
table) at the Runtime level alongside the existing Core-level
GroundedRootMotion_FrictionThreshold_DoesNotHammerLocomotionTests pin
(unmodified, still green).
- Update_RunningJumpLandsOnFlatGround_ResidualVelocitySurvivesAndDecays_NotFrozen:
a real charged running jump lands on flat ground and its residual
horizontal speed survives the first post-landing tick, then measurably
decays (dot(velocity, (0,0,1)) ~ 0 < 0.25, so friction engages here,
unlike the sloped roof capture where it doesn't).
Building the second test surfaced two genuinely separate, already-
correctly-scoped mechanisms unrelated to #265/#166, requiring no
production change: MotionInterpreter.LeaveGround (CMotionInterp::
LeaveGround 0x00528b00, R3-W4/J7/J8) recomputes velocity from the
CURRENT interpreted command on the grounded->airborne edge tick, so the
test holds Forward for one extra tick before releasing it; and
MotionInterpreter.ApplyCurrentMovementInterpreted's AP-77 "animation-less
/headless movement fallback" (already correctly scoped in the
divergence register) independently rewrites grounded velocity when no
DefaultSink is wired, so the test wires a minimal
FakeAnimationDispatchSink to match production's always-wired sink.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Capture bisect (docs/research/2026-07-30-265-capture-bisect.md, mined
from artifacts/matrix-session2-resolve.jsonl records 3415-3434) traced
#265's lost roof slides / permanent landing freeze and #166's missing
downhill sled to a pre-existing (2026-07-20, ten days before Campaign P
- not a regression) mechanism in PlayerMovementController.cs's grounded
quantum block: it hand-zeroed Velocity.X/Y to exactly zero every tick
once OnWalkable whenever animation root motion drives the walk (the
production graphical local-player path), discarding any residual
horizontal momentum a fall left on the body before calc_friction
(AP-7/AD-55, already correctly ported) or PhysicsBody.
UpdatePhysicsInternal's Euler integrator ever got a chance to act on it.
Two changes:
1. PhysicsEngine.cs now syncs body.GroundNormal (the vector
calc_friction dots velocity against, per retail
CPhysicsObj::calc_friction 0x0050ee70's `contact_plane.Normal` read)
from the committed ContactPlane.Normal at the same commit point that
already publishes ContactPlane. GroundNormal had zero production
writers before this and silently defaulted to Vector3.UnitZ forever
- even surviving velocity would have been tested against a fake
flat-ground normal on any real slope. Core-level, so player, remote,
ordinary, and projectile movers all benefit uniformly.
2. PlayerMovementController.cs's grounded block no longer reconstructs
Velocity at all for the animation-root-motion case (only the
headless/test-controller get_state_velocity fallback still does,
unchanged). Root motion continues to fully own commanded locomotion;
this only stops destroying whatever Velocity already holds, letting
it compose with root motion through the same ResolveWithTransition
sweep exactly as retail's CPhysicsObj::UpdatePositionInternal
composes both channels.
Symptom (a), the uphill-jump bounce, traces to a SEPARATE, byte-exact
(re-verified against acclient_2013_pseudo_c.txt:282647-282760),
already-closed retail mechanism (AD-25, PhysicsObjUpdate.
HandleAllCollisions's shouldReflect gate) - confirmed orthogonal to this
fix, not addressed here (see the research doc's as-fixed addendum §9.5).
Issue265SteepSlopeCaptureBisectTests.cs gains a composed harness
(ReplayRealRoofLandingComposed) mirroring PlayerMovementController.cs's
per-tick composition against Core types only, proving: the old model
reproduces the mined freeze exactly; the new model survives the landing
and slides continuously (the real captured geometry glides at constant
velocity per retail's own dot>=0.25 early-return - AP-7); a synthetic
dot<0.25 case shows genuine exponential decay via calc_friction; and a
synthetic uphill-bounce case proves the fix changes nothing about
HandleAllCollisions's reflection decision.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Raw byte decode of MovementSystem::GetRunRate (0x006b0950, PDB-paired
binary): fild skill; fcom [800f]; fnstsw; test ah, 0x44; jp general —
the C2/C3 parity idiom whose 18/4 fall-through executes ONLY at
skill == 800 exactly. ACE read this as >= 800 ('max run speed?') and
Campaign P P1 inherited that misread when BN dropped the arithmetic,
flat-lining every maxed character at 4.5 (retail-true ~3.70, +21%) and
erasing the vitae differential (both 10200 and 15225 sat above 800).
The [stat-chain] live capture proved the enchant chain correct end to
end (vitae 0.67 -> eff run 10200 -> controller), isolating the formula.
General path byte-verified: (loadMod*(skill/(skill+200)*11)+4)/scaling/4.
InqMaxRunRate's skill=9999 probe gets ~3.6961, not 4.5.
Golden tests pin the 799/800/801 straddle and the maxed-skill vitae
differential; pseudocode doc §6 carries the decode plus a do-not-
reimport-ACE warning. Complete Release suite: 10,025 passed / 5 skips.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Retail CACQualities::EnchantAttribute (0x00594570), EnchantAttribute2nd
(0x00594670, already ported for #6), and EnchantSkill (0x005947b0) are the
three enchantment-composition functions the Character window's Attributes
and Skills tabs depend on. Primary attributes never reference the vitae
singleton in retail (only Attribute2nd/Skill do) — confirmed directly from
the decompiled function bodies, not assumed.
EnchantmentMath.GetMod gains requiredType/includeVitae parameters (default
to the prior behavior) so a numeric StatMod key collision across domains
(e.g. key=1 is both Strength and MaxHealth) can't leak a buff into the
wrong computation. Spellbook.GetAttributeMod/GetSkillMod and
LocalPlayerState.GetEffectiveAttribute/GetEffectiveSkill/
GetSkillVitaeModifier wire the retail chain through to the panel.
CharacterSheetProvider now reports the effective value as the main number
and CharacterSkill.CurrentLevel is no longer an alias of BaseLevel (this
also activates the previously-dead SkillValueColor buffed/debuffed row
coloring). CharacterStatController's footer-title parenthetical is cited
from gmAttributeUI::DisplaySelectionFooter_Attribute (0x0049d280) and
gmSkillUI::DisplaySelectionFooter_Trained (0x0049b860) +
SkillInfoRegion::GetVitaeModifier (0x004f0fa0): skills show up to two
segments (vitae's own contribution, then the buff-only residual), while
vitae-immune attributes show at most one; no parenthetical when the delta
is zero. The panel now refreshes on Spellbook.EnchantmentsChanged, not only
raw property/attribute updates.
Core goldens cover the user-reported 33% vitae example (303->203, "(-100)"
exactly), buff+vitae composition, and the attribute vitae-immunity finding.
Provider/controller tests cover the full row-click -> footer-title path and
live refresh. Full solution suite passes with zero failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Root-caused via segment mining + a real-trajectory replay harness
(previous commit). Mined two dramatic real "velocity annihilation +
permanent freeze" events from the live capture (a high-speed fall landing
on a moderate, walkable-by-threshold roof slope, then a full velocity
zero + frozen position for the rest of the capture - 12,292 ticks for the
worse of the two).
A/B verdict: S1 (db2889af, #116 shape-1's Path-6 hasSphere1 change) is
provably UNREACHED for the mined trajectory - hit1 never fires once across
the 80-tick replay, and diagnostic instrumentation shows the landing
actually goes through the still-unchanged sphere0 (foot) branch. Reverting
S1 locally produced byte-identical replay output, confirming this
mechanically rather than by inference. S2 (calc_friction's AP-7 threshold)
has zero production call sites (grep-confirmed) - it is dead code and
cannot affect any live behavior in either direction.
The real mechanism, hand-traced against both mined events exactly: the R6
"grounded movement is animation-root-motion-owned" architecture
(PlayerMovementController.cs:1868-1882, landed 2026-07-20 via f961d700,
ten days before Campaign P) unconditionally zeros horizontal Velocity
every tick once OnWalkable is true. With no held movement key at the
instant of landing, the mover never advances again - a frozen-phase design
predating Campaign P entirely, not a regression from S1/S2.
Recommended direction: do not revert S1 (a real, narrow, retail-faithful
fix unrelated to these two events); do not touch S2 until it's actually
wired into a live path; the real target is #166 (downhill sled) plus the
grounded-movement architecture, which needs a brainstorming pass before
any implementation, not a quick S1/S2 revert.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds tools/analyze_265_steep_slope_capture.py (segment miner for the
ACDREAM_CAPTURE_RESOLVE JSONL captures: uphill-jump-bounce and
lost-slide/edge-wedge signature scans) and
tests/AcDream.Core.Tests/Physics/Issue265SteepSlopeCaptureBisectTests.cs (a
synthetic single-polygon PhysicsEngine that replays the EXACT real captured
ballistic approach + landing from artifacts/matrix-session2-resolve.jsonl
records 3415-3434, driving PhysicsEngine.ResolveWithTransition directly at
the Core boundary).
Mining found two dramatic real "velocity annihilation + permanent freeze"
events (records 3153/3159 and 3433/3434): a high-speed fall lands on a
moderate roof slope (normal.Z=0.857, ABOVE PhysicsGlobals.FloorZ — walkable
by threshold), and the very next tick shows Velocity forced to exactly
(0,0,0) with the position frozen byte-identical for the rest of the capture
(12,292 ticks to EOF for the second event).
No production code changes. Full Core.Tests suite: 4070 passed / 2 skipped.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three permanent low-volume probes: installed vitae record (id/type/key/
value) at ReplaceManifest, any vitae record dropped by GetMod's
spell-table prepass (retail's _vitae singleton never runs family
stacking - if this fires, hoist the Bucket-4 branch), and the full
recompute state (base skills, runMod, effective skills, active count).
One instrumented relog decides the break point.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ports CMotionInterp::get_adjusted_max_speed (0x00527D00, byte-decoded:
bare rate unless RunForward; forward_speed x 4.0 when running;
current_speed_factor proven a ctor-constant 1.0 at 0x00528C34) and swaps
all five interpolation catch-up call sites to it - retail's
fUseAdjustedSpeed_ static (.data 0x0081F418 = 1) makes this the live
branch, so standing/walking remotes now catch up at ~2x runRate instead
of 4x too fast (the #41/#165 presentation family). Autorun now hard-
forces Run for its duration and cancels on every fresh forward press
(CommandInterpreter::HandleNewForwardMovement 0x006b3d60 is literally
SetAutoRun(0,1)); the old test pin codified the divergence. AP-30
retired: retail Frame::is_equal genuinely uses the 0.0002 epsilon - the
row recorded a non-divergence. Three catch-up test pins re-baselined to
retail semantics with citations. Full Release suite 9,983/0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Their 2026-07-05/17 'pending user visual gate' statuses are superseded by
the consolidated Campaign P matrix; automated backing since the fixes
(R6 acceptance, nine-stop soaks incl. today's PASS, P3 conformance
suites) is recorded per entry. The matrix result closes or reopens each.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
TS-25's outbound stance has shipped via RawState.CurrentStyle since #219
(PlayerMovementController :1091/:1423/:1458, LocalPlayerOutboundController
:242) - the row's GameWindow cites predate the decomposition. TS-24's
empty action list is byte-identical to retail's no-pending-actions state
(feature gap, not behavior divergence) -> AD-57. TS-40's InWorld flag is
a structural adaptation of retail's cell-pointer-null idiom with a
recorded equivalence -> AD-58. Zero goal-enumerated physics stopgap rows
remain.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Per docs/research/2026-07-30-ts4-116-oracle-plan.md, following the four
code commits that closed out TS-4 (5e2be19b), #116 shape-2 (01492205),
and AD-55 (252e8068), plus #116 shape-1's Path-6 fix (db2889af) and the
TransitionalInsert return-value fix (7e1be3de):
- ISSUES.md #116: shape-2 marked CLOSED (D4 un-skipped, structurally
confirmed, no cdb needed). Shape-1 narrowed, not closed: the Path-6
head-sphere fix is a real, independent improvement but the tick-22760
confirming replay showed it doesn't explain that specific symptom --
the mover is grounded there (Path 5, not Path 6) and the actual
no-normal-recorded mechanism (SpherePath.PrecipiceSlide's
find_crossed_edge-false fallback) is independently confirmed byte-exact
retail behavior too. Recorded the concrete next step (re-run against
the faithful Setup-based door registration instead of the simplified
fixture) rather than closing on an unmet acceptance criterion.
- ISSUES.md #166: noted TS-4 and AD-55 landed (the AP-7-family
completion this note was waiting on); closure still pends the visual-
matrix scenario-5 recheck against live retail.
- Campaign P plan (2026-07-29-physics-parity-campaign.md) P2 status
block: TS-4 outcome (retired, not deferred), #116 outcome (shape-2
closed / shape-1 narrowed), AD-55 outcome (retired).
Docs-only; no build/test change required for this commit.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Per docs/research/2026-07-30-ts4-116-oracle-plan.md Addendum (byte-proven
2026-07-30). Raw bytes of CPhysicsObj::calc_friction @ 0x0050ee70's
Sledding fast-sled branch (0x0050ef52-0x0050ef6a):
d9 86 38 01 00 00 fld dword [esi+0x138] ; contact_plane.Normal.Z
dd 05 28 6b 7c 00 fld qword [0x007c6b28] ; = 0.17453292519943295 (10 deg RADIANS)
d9 ff fcos ; st0 = cos(10 deg) = 0.984807753
de d9 fcompp
confirm a genuine fcos opcode over a real 10-degrees-in-radians double
literal -- not a BN misdecompile of a raw float load. Retail truly
computes cos(10 deg) ~ 0.9848078 at runtime; ACE's 0.99999536f equals
cos(0.1745 DEGREES) -- the same radian literal evaluated in degree mode,
a proven ACE porting error carried into this port provisionally.
PhysicsBody.calc_friction's Sledding near-flat override now compares
GroundNormal.Z > 0.98480775f (cos 10 deg). Feel impact: retail's 0.2f
fast-sled friction override engages on any ground within 10 degrees of
flat; the old constant engaged only within ~0.175 degrees (never, in
practice).
Tests: two new boundary pins
(calc_friction_sledding_fast_override_engages_at_5_degrees_from_flat /
..._does_not_engage_at_15_degrees_from_flat) construct a tilted
GroundNormal with velocity purely orthogonal to the tilt plane (dot=0
exactly, isolating the Sledding-band friction value from the outer 0.25f
gate and the normal-removal step) and assert the exact pow(1-friction, dt)
decay on each side of the new 10-degree boundary.
Register: AD-55 retired (struck through, retirement note with the byte
decode).
Full AcDream.Core.Tests suite: 4063 passed / 1 skipped, no regressions.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Per docs/research/2026-07-30-ts4-116-oracle-plan.md §3, §3.3 step 1.
Test-only change with zero production code in this commit: TS-4's
retirement (5e2be19b, the previous commit) is what actually unblocks
this test's routing.
The plan's confirming instrumentation (probes added in 5e2be19b: which
BSPQuery.cs path fires, whether Path 4's FindWalkableInternal finds a
walkable candidate) traced D4's tall-vertical-wall scenario: Path 6 fires
(SetCollide, no reposition, Adjusted) -> the retry routes to Path 4
(find_walkable), which finds NO candidate for this sheer wall
(changed=false, confirmed) -> Path 4 returns OK -> TransitionalInsert's
Phase 3 sp.Collide block runs (reachable now that Phase 1/2 both return
OK): ContactPlaneValid is false (first airborne contact) so the reset
branch fires, LastKnownContactPlaneValid is false too (first frame), so
SetCollisionNormal(sp.StepUpNormal) runs and the function returns
Collided -- a hard stop, in place, with the wall's real normal. This
exactly reproduces the test's original (pre-Skip) expectation, confirming
the oracle plan's §3.1/§3.2 structural finding without a live cdb trace.
Full AcDream.Core.Tests suite: 4061 passed / 1 skipped (the remaining
skip is the unrelated Pvs_CottageInterior_MatchesRetailCellDrawList),
no regressions.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Per docs/research/2026-07-30-ts4-116-oracle-plan.md §1, §4 item 2 (the
decisive TS-4 confirming run). Retail's BSP layer has NO steepness test at
all (acclient_2013_pseudo_c.txt:323783-323821, 0x0053a793) — every airborne
hit, steep or shallow, falls through to the same unconditional
`SetCollide` + `Adjusted`. The L.4 slide-tangent shortcut (worldNormal.Z <
FloorZ -> project-and-Slid, with its own SetSlidingNormal write) is deleted
from both BSPQuery.cs's and FlatBspQuery.cs's Path 6 sphere0 branch.
Fixing FlatBspQuery.cs (the flat/indexed engine Slice I6/I7 made
production-authoritative) was necessary in this same commit: it carried an
exact structural duplicate of the shortcut, caught by
FlatBspQueryDifferentialTests.InstalledDat_LargeRandomizedSweep_HasZeroBitMismatch
(graph=Adjusted vs flat=Slid) once the graph side was fixed alone. Its
sphere1 branch is also brought in line with the #116 shape-1 fix landed
in db2889af (direct Collided + SetCollisionNormal instead of the deferred
SetCollide/shortcut treatment) — that parity gap existed since shape-1's
commit only touched BSPQuery.cs and the randomized differential sweep
didn't happen to exercise the narrow foot-clear/head-hit case until this
session's broader change surfaced it.
DECISIVE CONFIRMING RUN (Ts4SteepRoofWedgeCaptureTests, per the plan's own
required test-first order): added
FallOntoSteepSlope_WithHorizontalVelocity_NeverFreezesForOverHalfASecond_AndReachesFloor
— the same steep-roof drop as the existing pure-vertical fixture, but with
a small residual horizontal velocity (vx=-0.3 m/s), matching the realistic
live-play input (WASD, jump momentum) that validated the shortcut on
2026-04-30. With the shortcut removed, this variant converges cleanly to
the flat floor with zero freeze. The pure-vertical fixture, run
shortcut-removed, DOES still freeze — per the oracle plan's root-cause
trace (§1.2 Step E), this is the DEGENERATE case: AdjustOffset's crease
projection (Cross(ContactPlane.Normal, SlidingNormal)) is mathematically
orthogonal to a purely-Z gravity offset, crushing it to zero every tick
before TransitionalInsert can run again — present identically in the raw
decomp, ACE's port, and this port. Renamed and re-asserted as a PINNED
known-degenerate test
(FallOntoSteepSlope_PureVertical_FreezesAtDegenerateFixedPoint_RetailParity)
rather than treated as a bug. Filed as register row AD-56.
BSPStepUpTests.C3_Path6_AirborneMoverHitsSteepSlope_ReturnsSlid pinned the
OLD shortcut's Slid-no-Collide behavior directly; renamed to
...ReturnsAdjustedAndSetsCollide and corrected to the retail-faithful
Adjusted/Collide=true outcome.
Register: TS-4 row retired (struck through, retirement note); AD-56 filed
for the pure-vertical degenerate case; the retire-next shortlist's TS-4
entry removed and renumbered.
Full AcDream.Core.Tests suite: 4060 passed / 2 skipped (D4 stays Skip-tagged
in this commit; its own un-skip is a separate, dependent test-only commit
for #116 shape-2), no regressions.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Per docs/research/2026-07-30-ts4-116-oracle-plan.md §2.3-§2.4: retail's
airborne (not-yet-Contact) BSPTREE::find_collisions dispatch, when the
FOOT sphere is completely clear but the HEAD sphere hits or near-misses,
does not defer through SetCollide/Adjusted (nor the steep-poly
slide-tangent shortcut) — it records the head polygon's normal directly
and hard-stops: pc:323824-323834 (0x0053a793/0x0053a7a4), independently
cross-checked against ACE BSPTree.cs:221-230 (`SetCollisionNormal` +
`return TransitionState.Collided;`), an exact structural match confirming
this isn't a BN misdecompile. BSPQuery.cs's Path 6 `hasSphere1` branch now
does the same: `collisions.SetCollisionNormal(worldNormal1); return
TransitionState.Collided;`, replacing the old steep-shortcut-or-deferred-
SetCollide handling. This mechanically retires one of TS-4's two
`SetSlidingNormal` write sites (sphere1's) ahead of TS-4's own item.
Added two permanent diagnostics gated on the existing
PhysicsDiagnostics.ProbeIndoorBspEnabled flag (`[path-dispatch]` at
FindCollisionsCore entry, `[path5-diag]` inside Path 5) to make future
BSPQuery dispatch tracing cheaper.
HONEST RESULT of the plan's own confirming instrumentation (re-run of
DoorBugTrajectoryReplayTests.Diagnostic_Tick22760_DumpEngineInternals):
this fix does NOT change the tick-22760 outcome (harness still cn=(0,0,1)
vs live cn=(0,+1,0)). The new dispatch-entry probes show the tick-22760
mover is GROUNDED (Contact set), so it never reaches Path 6 at all — it
dispatches Path 5 -> StepSphereDown (Path 3, both DoStepDown half-steps
fail) -> EdgeSlideAfterStepDownFailed -> SpherePath.PrecipiceSlide, whose
find_crossed_edge-false fallback returns Collided with NO collision-normal
write. A fresh byte-level read of retail's SPHEREPATH::precipice_slide
(pc:274316-274326, 0x0050cc80) confirms this is byte-exact retail
behavior (`if (eax == 0) { walkable = 0; return 2; }`, no
set_collision_normal call) — not a bug. The real tick-22760 divergence is
further upstream, most likely this test's simplified door registration
(BuildEngineWithDoorFixture) not placing the door's BSP where live retail
actually intersected it, or a walkable-polygon state-capture gap — see
the research doc's Addendum 2 for the full trace and open candidates.
#116 shape-1 is therefore NARROWED, not closed: the Path-6 fix is a real,
independent retail-faithfulness improvement; the tick-22760 acceptance
criterion is not met by it and needs further harness/geometry work before
any further code change.
Full AcDream.Core.Tests suite: 4059 passed / 2 skipped, no regressions.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Per the TS-4/#116 oracle plan (docs/research/2026-07-30-ts4-116-oracle-plan.md
§1.4, §4 item 1): TransitionalInsert's retry loop hardcoded
`return TransitionState.Slid;` when the attempt budget exhausted, despite
the comment's own claim of returning "whatever the last iteration said."
ACE's equivalent (Transition.cs:933, `return transitState;`) and retail's
(pc:273363, 0x0050b949, `return edi;`) both reuse one state variable
across the composite per-attempt call and return whatever it holds.
acdream's per-phase dispatch (env/building/object/other-cells/neg-poly/
step-down) is split across several locals instead of ACE's single
composite call, so `transitState` is now re-synced from whichever
phase-local variable most recently caused a retry `continue`, and the
final return uses that real value instead of the hardcoded constant.
Blast radius is zero: ValidateTransition's "not OK" branch treats
Collided/Adjusted/Slid identically, and every caller of TransitionalInsert
either feeds the result straight into ValidateTransition/
ValidatePlacementTransition (both `== OK` vs. not) or checks `== OK`
directly. Full AcDream.Core.Tests suite: 4059 passed / 2 skipped, no
change in pass count.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
PhysicsBody.IsFullyConstrained now reflects real ConstraintManager state
(pushed every tick by the same per-tick pumps commit 2 wired), so
jump_is_allowed's already-ported gate (WeenieError 0x47) actually fires
while an object is rubber-banding hard against a server position
correction, closing the last piece of #167.
Housekeeping:
- Delete register row TS-35 (retired: the write side is no longer stubbed).
- Rewrite the stale doc comments on PhysicsBody.IsFullyConstrained,
ConstraintManager (class + IsFullyConstrained), PositionManager.ConstrainTo,
EntityPhysicsHost.PositionManager, and PlayerMovementController.PositionManager
that described the leash as permanently unarmed/stubbed.
- Close#167 in ISSUES.md citing the research doc and commits e0629145 /
7719d25b.
- Add an "as-ported" addendum to
docs/research/2026-07-30-constraint-leash-constants.md naming the actual
current seam owners (the doc's own open question flagged this as
implementer-verify-required post-J-slices).
- Update docs/plans/2026-07-29-physics-parity-campaign.md's P5 status and
CLAUDE.md's Campaign P summary to reflect #167's closure (items #153/#72
remain open in P5).
Verification: complete solution suite green - 9,978 tests, 5 skips, 0
failures across all 9 test projects (Core.Tests, Runtime.Tests, App.Tests,
Headless.Tests, Core.Net.Tests, Content.Tests, UI.Abstractions.Tests,
Bake.Tests, Cli.Tests).
Wire ConstraintManager.ConstrainTo at every current acdream inbound-position
acceptance seam, matching retail SmartBox::HandleReceivedPosition
(0x00453fd0):
- Remote (player + NPC): LiveEntityNetworkUpdateController arms right after
the hard-teleport branch (remotePlacementRequired) returns - reaching that
point already means MoveOrTeleport did NOT hard-place - anchored to the
object's own live IPhysicsObjHost.Position.
- Local player teleport: PlayerMovementController.SetPositionCore now runs
UnConstrain (retail teleport_hook 0x00514ed0, previously a no-op because
nothing armed the leash) then re-arms anchored to the just-snapped
position, composing with the existing StopCompletelyAtPhysicsObjectBoundary
velocity zero rather than duplicating it. CommitPreparedPosition mirrors
the same pair for the deferred player-mode-entry commit path.
- Local player ForcePosition: PlayerMovementController.BlipPosition arms
with NO preceding UnConstrain (retail BlipPlayer/SetPositionSimple
survives motion/velocity/stick, and the leash is no different).
Push PhysicsBody.IsFullyConstrained from PositionManager.IsFullyConstrained
at the SAME per-tick chokepoint each pump already runs AdjustOffset
(PlayerMovementController.Update, RuntimeRemotePhysicsUpdater.Tick/TickHidden)
so TS-35's read gate in jump_is_allowed sees live state instead of a stub
that is never written.
Tests: local-player arm/teardown/rearm/taper/jump-refusal (Runtime.Tests,
PlayerMovementControllerTests), remote-tick IsFullyConstrained push
(Runtime.Tests, RuntimePhysicsStateTests). Full Core/Runtime/App suites
green with no regressions.
Add ConstraintDistance (outdoor/indoor start=10/5, max=50/20), byte-decoded
from the matching retail binary (GetStartConstraintDistance 0x0050ebc0,
GetMaxConstraintDistance 0x0050ec10 - both x87-return functions BN elided).
Deliberately omits the vestigial player-vs-remote branch the disassembly
shows loads identical constants either way. Pins the ACE-inversion (ACE's
start mapping is outdoor 5/indoor 10, the opposite of the binary - the
binary wins). Adds a full-chain conformance test proving an armed,
over-strained leash actually blocks jump_is_allowed (0x47), not just the
bare stub-property regression already covered.
See docs/research/2026-07-30-constraint-leash-constants.md.
P4's original AP-71 landing shipped CanMoveInto deliberately unmodeled
(fail-closed default, AP-129). The review found this locks the entire
housing estate (103,766 of 729,888 installed EnvCells) for every player
including its own owner. Records the fix (7a0f836a) and the updated gate
totals.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Campaign P Slice P4 Opus review verdict: FIX-FIRST. RestrictionObjPrevalenceInspectionTests
(commit 3b5e0992) found 103,766 of 729,888 installed EnvCells (1,293 landblocks -
the whole housing estate) carry a baked RestrictionObj. The AP-71 gate's
unconditional fail-closed default (CanMoveInto unmodeled) would have locked
every apartment/cottage/villa interior for every player, including its own
owner - a live regression, not the "inert in dev content" the original
register row assumed.
Ports ACCWeenieObject::CanMoveInto (0x0058da40, pc:407982-408056) and
RestrictionDB::IsAllowedIn (0x005ae8f0, pc:444493-444516) verbatim into
ObjectInfo.CheckEntryRestrictions:
- owner_iid == 0 or == mover's own guid -> admit (open/owner)
- no RestrictionDB (retail _db == 0, i.e. never authored or not yet
received) -> admit
- present RestrictionDB -> IsAllowedIn: open-to-public flag, OR mover
shares the house's allegiance monarch, OR mover's own guid is a
guest-table member
- unresolved restriction object -> fails CLOSED, exactly retail's own
fallback when GetObjectA can't resolve it (pc:704-716)
Wire feed (Core.Net):
- CreateObject.cs: HouseOwner (WeenieHeaderFlag 0x02000000), HouseRestrictions
(0x04000000), and Monarch (0x40) PWD-tail fields were parsed-and-skipped;
now captured. Also fixes the HouseRestrictions PHashTable header
misconception: the wire is ONE packed u32 (low 24 bits = entry count),
not a separate count(u16)+numBuckets(u16) pair - verified against
Chorizite's RestrictionDB.generated.cs. The old skip's byte-count
happened to match for realistic guest-list sizes, but a future
numBuckets value >255 would have corrupted the parse; now correct
regardless.
- GameEvents.cs/GameEventWiring.cs: new House_UpdateRestrictions (0x0248)
parser + wiring - retail's live guest-list refresh, whole-unit replace.
No-ops if the house object hasn't arrived via CreateObject yet.
- ClientObject/WeenieData/ClientObjectTable: HouseOwnerId, MonarchId,
Restrictions (new HouseRestrictionRecord) fields + merge-preserving
Ingest + targeted UpdateHouseRestrictions.
Physics wiring:
- PhysicsEngine gains an Objects (ClientObjectTable?) property, mirroring
the existing DataCache pattern - acdream's GetObjectA equivalent, used
ONLY by the entry-restriction gate.
- RuntimeEntityObjectLifetime wires Physics.Engine.Objects = Objects in
all three constructors, right alongside the table's own construction -
the same canonical table every other subsystem borrows from, never a
second one. This is the production fix: without it the gate still fails
closed on every restricted cell (unresolvable object), so the wiring is
load-bearing, not cosmetic.
Register: AP-129 narrowed (not retired) to the genuine remaining residual -
House_UpdateRestrictions' Sequence byte isn't used for staleness/reordering
rejection (low-probability, self-correcting), and outdoor CLandCell
restriction (a separate DAT structure) remains unported and unaffected by
this fix.
Tests: 15 new/updated in Ap71EntryRestrictionGateTests.cs (resolved-unowned
admits, owner admits, present-list-excluded blocks, present-list-included
admits, open-to-public admits, shared-allegiance-monarch admits, unresolved
blocks via null and via an empty table, plus two new end-to-end
PhysicsEngine.Objects-wired scenarios); 2 new CreateObject parser tests +
2 new GameEventWiring tests for the wire feed.
AcDream.Core.Tests: 4049 passed, 2 skipped, 0 failed.
AcDream.Core.Net.Tests: 761 passed, 0 skipped, 0 failed.
Complete solution suite: 9,961 total, 9,956 passed, 5 skipped, 0 failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two tests proved "these two unrelated DAT reads ran concurrently" by
racing a fixed Thread.Sleep(40) window against .NET thread-pool
scheduling latency for a second Task.Run. Under the CPU contention of
a full `dotnet test AcDream.slnx` run (all 9 test projects' VSTest
hosts launch concurrently) plus a busy machine, thread-pool injection
can occasionally miss the window, making MaxConcurrentReads read 1
instead of 2 and failing the assertion with no underlying code defect.
RetailAnimationLoader and RetailPhysicsScriptLoader both coalesce
same-key reads correctly via ConcurrentDictionary<K, Lazy<T>>.GetOrAdd,
which is atomic and timing-independent (verified by reading, not just
running) - only the test's method of proving cross-key overlap was
timing-fragile. DecodedTextureCacheTests already uses the correct
deterministic-gate pattern; this brings RetailDatLoaderTests in line
with it via a Barrier-backed rendezvous instead of a sleep race.
Filed as #248 (docs/ISSUES.md) with the full attempt matrix: could not
catch the originally-reported AcDream.Content.Tests failure in the act
despite ~72 Content.Tests executions across four contention strategies
over ~30 full-suite-equivalent runs, though the general mechanism
reproduced 3x in AcDream.App.Tests's already-known zero-allocation
flake class (left untouched, out of scope here).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
103,766 of 729,888 EnvCells (14%, 1,293 landblocks - the entire housing
estate, 0x70xxxxxx GUIDs) carry a baked RestrictionObj. The AP-71 gate
as wired (CanMoveInto unmodeled, fail-closed) would therefore lock every
housing interior for everyone; retail's CanMoveInto (0x0058da40) is
fail-OPEN for unowned houses and for a null RestrictionDB. Fix directed
back to the P4 implementer.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both P4 items landed (d6c3f865 AP-71, cc8d57a2 AP-10). Records the slice-gate
complete solution suite totals: 9,946 total across 9 test projects, 9,941
passed, 5 skipped, 0 failed on a clean run. One flaky unrelated failure
(AcDream.Content.Tests parallel-cache-coalescing timing test, untouched by
this slice) observed on an earlier run in the same session; reproduces 0/2
in isolation and passed clean on immediate re-run, confirming full-suite
parallel-contention flakiness rather than a regression.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Campaign P Slice P4 item 2. TerrainSurface.SampleWaterDepth now returns 0.1
(was collapsed to 0) for a partially-water cell's dry corner, matching
retail's ObjCell.get_water_depth / calc_water_depth (via ACE's unambiguous
C# port). ValidateWalkable's formula was already byte-for-byte verbatim
(ACE ObjectInfo.ValidateWalkable line 124); only the constant was collapsed.
The old collapse's justification ("0.1 destabilizes the feet-exactly-on-plane
contact-touch check because dist > EPSILON skips SetContactPlane that tick")
is structurally true of retail too - traced and confirmed this slice: in ALL
THREE implementations (retail, ACE, acdream) a skipped touch-reassertion is
NOT a fall, because Contact/OnWalkable are STICKY -
PhysicsEngine.ResolveWithTransition's onGround computation ORs the fresh
per-call ContactPlaneValid with the seeded, persistent
PhysicsBody.TransientState.OnWalkable bit (itself written back by the
caller's own sticky TransientState). PhysicsEngine.SampleTerrainWalkable's
isWater = waterDepth >= 0.45f threshold means the restore does not flip the
dry corner's water classification (0.1 still < 0.45) - only the sink-in
depth changes. Full Core.Tests suite green (4038/2 skips, up from 4026)
proves the sticky-bit argument held in practice.
WATER_CONTACT_TS (TransientStateFlags.WaterContact, declared but never
written) is now mirrored alongside CONTACT_TS/ON_WALKABLE_TS at every commit
point that writes them: PhysicsObjUpdate.ApplySetPositionContact (projectiles
+ remote teleport), PhysicsObjUpdate.CommitSetPositionTransition (remote
teleport placement), and PhysicsEngine's per-resolve body-state commit (local
player + remote dead-reckoning + ordinary movers via ResolveWithTransition -
the actual SetPositionInternal-equivalent path). No signature changes needed:
body.ContactPlaneIsWater is already fresh by the time each function runs.
CollisionShadowVerifier audit: no change needed. It diffs graph-vs-flat BSP
traversal outcomes (ObjectInfo/CollisionInfo/SpherePath fields already
including ContactPlaneIsWater); it never touches PhysicsBody.TransientState,
and the water-depth constant is computed identically upstream of both
traversal modes, so it cannot introduce a new graph/flat divergence.
Filed #264 for the three items research explicitly left open (none block
this port): no confirmed retail consumer of WATER_CONTACT_TS was found (an
xref scan wasn't attempted - bitmask reads aren't text-greppable); the
CLandCell ENTIRELY_WATER ethereal/swim exemption from terrain collision was
not cross-checked; jump-in-water/swim-animation effects were not
investigated (out of physics/collision scope).
Conformance: Ap10WaterSemanticsTests covers SampleWaterDepth golden values
(NotWater/EntirelyWater/PartiallyWater wet+dry corners), the isWater
threshold non-flip, WaterContact mirroring in both PhysicsObjUpdate
functions, and two settle-to-rest end-to-end PhysicsEngine.ResolveWithTransition
scenarios (water: sinks exactly waterDepth below the plane and sets
WaterContact; dry: rests exactly on the plane and clears any stale
WaterContact bit).
Register: retired AP-10 (92 active AP rows, down from 93).
AcDream.Core.Tests: 4038 passed, 2 skipped, 0 failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Campaign P Slice P4 item 1. Ports retail's CObjCell::check_entry_restrictions
(pc:308873-308912, 0x0052b6d0), called FIRST by CEnvCell::find_env_collisions
(pc:309576) before any BSP work, as ObjectInfo.CheckEntryRestrictions wired at
the top of the indoor branch of Transition.FindEnvCollisions.
Resolves the research doc's open question on restriction_obj's source: the
ACE cross-check (references/ACE/Source/ACE.DatLoader/FileTypes/EnvCell.cs:32,
66-67) plus an independent reflection probe of Chorizite.DatReaderWriter
2.1.7's own EnvCell.RestrictionObj field confirm it is a plain DAT-baked
uint32 gated by EnvCellFlags.HasRestrictionObj (0x8) - not a live wire
override. The BN pseudo-C's "count for an array alloc" read at the same
UnPack offset was the mis-attributed field-name collision
feedback_bn_decomp_field_names warned about.
CellPhysics.RestrictionObj is wired from envCell.RestrictionObj in BOTH the
dev/graph-fixture path (CacheCellStruct) and the production/prepared path
(CachePreparedCellStruct) - the latter already receives a live parsed
envCell for Position/EnvironmentId, so no bake-format change was needed.
The mover's own CanBypassMoveRestrictions (BF_ADMIN 0x100000 AND
BF_IMMUNE_CELL_RESTRICTIONS 0x400000, acclient.h:6452-6454) is decoded via
the same PWD-bitfield pipeline TS-23 established for PK/PKLite/Impenetrable
(EntityCollisionFlags -> ToMoverState -> ObjectInfoState moverFlags).
Remaining gap (filed as AP-129, replacing the retired AP-71 row): CanMoveInto
(house owner IID + guest/ban list) is unmodeled, so a genuinely restricted
cell fails CLOSED for everyone, not just intruders - matching retail's own
fallback when the restriction weenie can't be resolved (pc:704-716). Outdoor
CLandCell restriction (LandblockInfo.RestrictionTables, a separate DAT
structure) is explicitly out of scope for this gate.
Conformance: Ap71EntryRestrictionGateTests covers the pure gate logic
(NPC bypass, admin bypass, fail-closed, ordinary-cell no-op), the PWD-bitfield
two-bit AND decode, and three end-to-end Transition.FindEnvCollisions
scenarios proving zero behavior change for ordinary cells.
AcDream.Core.Tests: 4026 passed, 2 skipped, 0 failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
TS-46/AD-25/TS-23 retirements verified: sphere-list conformance decoy
pair proves the list drives the sweep; mover bits map the retail
OBJECTINFO::init 0x80/0x800/0x1000 space with the non-PK invariant
pinned; #165 correctly stopped at the render-lag candidate with (a)/(b)
ruled out by evidence. The PK-timer's process-uptime clock is a sound
precision choice but a latent cross-timebase compare against the wire's
server-basis PropertyFloat 0x91 - inert against ACE (neither property
modeled), filed as AP-128 rather than guessed at.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Campaign P Slice P3 item 4. Per the plan's explicit instruction, this
is diagnose-only: the research's candidate (a)/(b) mechanisms did not
confirm, so no fix lands here.
Built the dat-free/dat-backed fixtures the plan asked for (no live
client) to test the two mechanisms a physics fixture CAN discriminate:
- (b) ruled out by code reading: RuntimeRemotePhysicsUpdater.Tick's
resolve gate reads RuntimeEntityRecord.FullCellId live. Every
FullCellId = 0 write site (TryApplyPickup, CommitAcceptedParentCellless,
CommitWithdrawal in RuntimeEntityObjectLifetime.cs) is a pickup/
parent-attach/delete path, never reachable for a live, freely moving
remote mid-session. The "one-frame grace" is genuinely first-spawn-only.
- (a) tested directly and does not reproduce, on two independent
geometries: InterpolationManager's unclamped stall-fail "tail delta"
snap (node_fail_counter > 3) can hand ResolveWithTransition an
arbitrarily large single-tick targetPos. New fixture tests replace a
proven small-step sweep (many 0.08-0.10 m ticks) with ONE resolve call
spanning the entire distance, against both a synthetic creature sphere
and the real Holtburg door BSP slab (Setup 0x020019FF/GfxObj
0x010044B5, the existing door-apparatus dat fixture) already used by
DoorCollisionApparatusTests. Both stop at the identical surface
distance the small-step tests pin, with a valid collision normal --
the sweep is not distance-limited and does not tunnel on a large
single-tick delta.
Candidate (c) -- render/interpolation presentation lag on the App side --
is the remaining hypothesis and is out of scope for a physics-fixture
pass (it's a claim about what gets drawn relative to the committed
PhysicsBody.Position, not something a Core fixture observes). #165
stays OPEN with (a)/(b) struck from the candidate list by the evidence
above and (c) named as the next concrete step (an App-layer render-vs-
physics-position diff, or a fresh live ACDREAM_PROBE_RESOLVE capture).
New tests: Issue165RemoteWallPenetrationDiagnosticTests (dat-free,
3 tests) and DoorCollisionApparatusTests.
Apparatus_SingleLargeTickJump_DeadCenter_StillBlocksOnBSP (dat-backed,
1 test, skips gracefully without the local dat directory).
dotnet build + dotnet test (Core.Tests 4012/2 skip, Runtime.Tests
425/0) green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Campaign P Slice P3 item 3. The wire parse (CreateObject's
PublicWeenieDesc._bitfield), the decode (EntityCollisionFlagsExt.
FromPwdBitfield), the per-GUID storage (ClientObjectTable.
PublicWeenieBitfield), and the exemption logic (CollisionExemption.
ShouldSkip) all already existed and were already correct -- every
mover-flags call site just fed a GUID-prefix IsPlayer heuristic instead
of the real per-entity PK/PKLite/Impenetrable state (retail
OBJECTINFO::init 0x0050cf30 state |= 0x80/0x800/0x1000).
Port:
- EntityCollisionFlagsExt.ToMoverState translates the decoded PWD
bit-space into the ObjectInfoState bit-space FindObjCollisions
actually reads -- two different numberings that must not be
confused. Deliberately does not translate IsPlayer (every call site
already derives that correctly from its own GUID heuristic per
#184 Slice 2b).
- EntityCollisionFlagsExt.ResolveMoverPvpState is the one shared
ClientObjectTable-backed lookup (guid -> ObjectInfoState), replacing
what would otherwise have been three separate inline copies across
GameWindow/LivePresentationComposition/RemoteTeleportController.
- Threaded as a new optional moverPvpState parameter through
RuntimeRemotePhysicsUpdater.Tick/TickHidden and
RuntimeOrdinaryPhysicsUpdater.TryBegin (default None preserves every
pre-P3 caller unchanged), and as PlayerMovementController.OwnPvpFlags
for the local player's own two resolve call sites.
- TS-23 section 12b: PlayerWeenie.JumpStaminaCost's pk parameter now
reads the real PlayerKillerStatus(0x86)/LastPkAttackTimestamp(0x91)
pair against retail's 20-second recency window
(pkStatus in {4, 0x40} && (timestamp + 20.0) >= now), replacing the
P1 hardcoded false. RuntimeMovementSkillState/Snapshot and
LiveSessionEventRouter.RecomputePvpStatus push both the PWD bitfield
and the PlayerKillerStatus pair reactively, riding the SAME
ClientObject event triggers RecomputeBurden already uses.
- A conformance test caught a genuine precision bug in the first
PK-timer clock choice: DateTimeOffset.UtcNow's Unix-epoch seconds
(~1.7 billion) loses ~128 seconds of precision in a 32-bit float,
silently swallowing the entire 20-second window. Switched to
Environment.TickCount64 (small, monotonic magnitude) -- also the more
retail-plausible basis, since LastPkAttackTimestamp is itself a wire
PropertyFloat and retail's Timer::cur_time is almost certainly a
process/session-relative counter for the same precision reason, not
an absolute epoch.
Non-PK invariant (the acceptance criterion): an entity with no
ClientObjectTable row, or a row whose PublicWeenieBitfield is null or
0, resolves to ObjectInfoState.None -- a no-op OR into moverFlags,
bit-identical to every pre-P3 caller's hardcoded value. A dedicated
test drives two real ClientObjectTable rows through
CollisionExemption.ShouldSkip and confirms PK-vs-PK collides while
PK-vs-non-PK and non-PK-vs-non-PK both stay exempt (walk through).
Register: TS-23 retired (both the collision-flags and PK-timer halves);
the stale "M2 combat must land TS-23" phase-gate note removed.
dotnet build + dotnet test (Core.Tests 4008/2 skip, Runtime.Tests
425/0, App.Tests 3968/3 skip, complete solution build) all green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Campaign P Slice P3 item 2. CPhysicsObj::handle_all_collisions
(0x00514780, pc:282647) is one uniform function retail calls
unconditionally after every SetPositionInternal, player or remote. The
gate is shouldReflect = !(prevOnWalkable && nowOnWalkable && !sledding).
RuntimeRemotePhysicsUpdater.Tick's post-resolve reflect was still the
2026-07-05 (#173) hand-inlined block, gated on
resolveResult.CollisionNormalValid and using two ad-hoc branches that
diverge from retail in exactly the cases the register row named:
- non-sledding: old = "!prevOnWalkable && !nowOnWalkable" (reflects
ONLY airborne-before-AND-after); retail reflects on every transition
except grounded-before-AND-after.
- sledding: old = "!(prevOnWalkable && nowOnWalkable)" (suppresses the
bounce exactly when both grounded); retail's "!sledding" term forces
shouldReflect = true unconditionally when sledding, the opposite
polarity.
Both gaps meant a remote's post-landing reflect never ran on a
grounded-transition tick at all -- the "acdream lands clean and dead"
half of #166's slope-landing composite.
Replace the hand-inlined block with a direct call to
PhysicsObjUpdate.HandleAllCollisions -- the same verbatim port the
local player and every ordinary body already use via
CommitSetPositionTransition -- passing the same
prevContact/prevOnWalkable/nowOnWalkable values the old code already
computed. Narrower swap per the research's explicit recommendation:
does not fold in CommitSetPositionTransition's HitGround/LeaveGround
dispatch, leaving the remote's bespoke landing-detection block
(interp-queue-clear, animation-hook-specific logic) untouched. The call
is now unconditional (matching retail's own unconditional call site)
rather than gated behind CollisionNormalValid, since HandleAllCollisions
already no-ops the reflect step internally when no normal was found but
still runs the frames-stationary-fall bleed regardless.
PhysicsObjUpdate.HandleAllCollisionsTests already exhaustively pins the
retail formula in isolation; this change is a mechanical wiring swap to
the already-tested function using values the removed block already
computed. Full regression suites (Core.Tests 3991/2 skip, Runtime.Tests
425/0, App.Tests 3968/3 skip) pass unchanged -- no existing test pinned
the old broken formula.
Register: AD-25 retired (both the local-player and remote halves are now
the ported HandleAllCollisions); #166's reattribution note updated to
reflect the closure, leaving only TS-4 as the remaining blocker on that
issue's downhill-jump-glide acceptance.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Campaign P Slice P3 item 1. Retail CPhysicsObj::transition (0x00512dc0)
seeds the collision sweep from CPartArray::GetSphere (the Setup's own
<=2-sphere list, each origin+radius scaled by m_scale) via
SPHEREPATH::init_sphere (0x0050c670) -- not from a symmetric two-scalar
(radius, height) capsule reconstruction. The human Setup 0x02000001's
authored spheres are (0,0,0.475) r=.48 and (0,0,1.350) r=.48; the old
reconstruction from (0.48, 1.835) produced (0,0,0.48) + (0,0,1.355), a
5 mm head-center offset the TS-46 register row documented as a residual.
Port:
- SpherePath.InitPath gains a sphere-list overload (ImmutableArray<
FlatCollisionSphere>, scale) sharing a new InitPathCore with the
existing (radius, height) overload, which is now the degenerate
2-scalar case of the same code -- byte-for-byte unchanged, so every
captured-fixture replay (CellarUpTrajectoryReplayTests,
DoorBugTrajectoryReplayTests, CellarLipWedgeTests) keeps passing
unmodified.
- PhysicsEngine.ResolveWithTransition gains optional sphereList/
sphereScale parameters; empty/default preserves the legacy scalar
path for every pre-existing caller.
- LiveEntityMotionRuntimeController.GetSetupMoverShape is a new sibling
of GetSetupCylinder (left untouched) that resolves the Setup's own
sphere list plus Setup-derived step-up/step-down
(CPartArray::GetStepUpHeight/GetStepDownHeight, 0x005180d0/0x005180f0,
x ObjScale, 0.4 m fallback matching the pre-existing literal).
- Threaded through PlayerMovementController (both resolve call sites,
new SphereList property set by PlayerModeController.ApplyStepHeights
and the Headless world projection), RuntimeRemotePhysicsUpdater
(Tick + TickHidden), and RuntimeOrdinaryPhysicsUpdater.TryBegin.
Remote/ordinary step heights are now Setup-derived instead of a
hardcoded 0.4f literal. Projectile and camera-probe sweeps are
untouched (already single-sphere-exact).
- PlayerModeController.ApplyStepHeights also now applies the x ObjScale
multiply to the player's own step heights (previously only the
remote/ordinary paths did), closing an adjacent gap the P3 research
flagged.
Ts46SphereListConformanceTests proves the sphere-list overload sees the
exact dat spheres (not the reconstruction), that the scalar overload is
unchanged, and that ResolveWithTransition's sphereList parameter
actually drives the sweep (a decoy-scalar control pair using a
head-height obstacle sphere).
Register: TS-46 retired (both residuals it named are closed); header
count corrected to 40 active TS rows.
dotnet build + dotnet test (Core.Tests 3991/2 skip, Runtime.Tests
425/0, App.Tests 3968/3 skip, complete solution build) all green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The render-alpha clamp test compared physics vs render position with
xunit precision:4 (Math.Round semantics); the AP-7 friction port shifts
the velocity-fallback trajectory by 7.6 um, landing two essentially
equal values on opposite sides of a 5e-5 rounding boundary. Assert with
a 1 mm tolerance instead. Merged-tree full Release suite: 9,887 passed /
0 failed / 5 skips including Headless (the exposed velocity-fallback
path holds).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
All seven review lenses pass. CanJump's polarity is upgraded from
plausibility to proof: raw bytes of 0x00591b50 show fld load / fcomp
[0x007c5e24 = 2.0f] / test ah,5 / jp -> return 0, i.e. return 1 iff
load < 2.0 with unordered refusing - exactly the shipped code, NaN edge
included. UN-8 deleted. CACQualities::JumpStaminaCost's pk flag decoded
for P3: PlayerKillerStatus in {4,0x40} AND PropertyFloat 0x91 + 20 s >=
now.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
docs/research/2026-07-30-response-layer-edge-family-pseudocode.md §3, §6
Step 6. Corrects two things in the original AD-25+AP-7+TS-4 framing:
AD-25's local-player half was already ported by the #182 rebuild
(2026-07-07) and the remaining gap is remote/NPC-only (Campaign P P3
scope); and no client-side PhysicsState.Sledding auto-toggle exists
anywhere in the named-retail decomp or ACE's PhysicsObj.cs -- the only
Sledding write site in any reference repo is a per-weenie game-data
property, not a physics landing response, so this issue must not wait on
inventing one.
AP-7 landed this session. TS-4's removal was attempted per its own
fixture-first requirement and reproduced the historical 2026-04-30 wedge,
so it stays deferred (see its register row and the research doc's §7 item
6). Closure pends TS-4 actually landing and a fresh capture against the
campaign's final visual-matrix item 5.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Campaign P Slice P2 step 2-3
(docs/research/2026-07-30-response-layer-edge-family-pseudocode.md §4, §6
Step 3). Per the research doc's own port order, TS-4's Path-6 steep-poly
shortcut may only be removed after a fixture reproduces the original
"stuck in falling animation on a steep roof" symptom cleanly with the
shortcut disabled. No surviving live-session fixture exists from the
2026-04-30 L.4 commit (b1af56e); this adds a dat-free multi-frame capture
(Ts4SteepRoofWedgeCaptureTests) using BSPStepUpFixtures.SlopedUnwalkable's
63.4 degree slope, replayed at 30 Hz with gravity integrated between
PhysicsEngine.ResolveWithTransition calls -- the same idiom as
Issue185OutdoorStairsSeamReplayTests.
Against today's baseline (shortcut active) the capture is green, as
expected (the shortcut's explicit AddOffsetToCheckPos keeps the body
moving every tick by construction).
Scratch-removed the shortcut (both BSPQuery.cs sphere0/sphere1 branches,
not committed -- reverted after capture) and re-ran the same test: the
body falls and lands cleanly on the steep polygon at tick 17 (InContact,
OnWalkable=false, via retail's own permissive CTransition::check_walkable
LandingZ gate, pc:273202), then freezes at that exact position for the
rest of the run -- the exact historical wedge shape, tripping the test's
own >0.5s-frozen threshold at tick 33.
Root-cause diagnosis via ACDREAM_DUMP_EDGE_SLIDE=1: the freeze is upstream
of EdgeSlideAfterStepDownFailed/CliffSlide entirely (none of that
dispatch's diagnostics fire). TransitionalInsert's Phase 2 object-collision
check returns Adjusted on every retry attempt because Path 6's retail-
faithful SetCollide returns ADJUSTED_TS without repositioning the sphere
(unlike the interim shortcut, which explicitly pushes the sphere off the
face) -- the same steep polygon re-triggers Path 6 on the immediate retry,
forever, and Phase 3 (the sp.Collide handling that contains DoCheckWalkable,
the Placement re-test, and the TS-1 CliffSlide chain) is gated on Phase 1
AND Phase 2 both returning OK, so it is structurally unreachable from this
state. TS-1's completeness is moot here -- the code path that would call
into it never runs.
Per the mission's explicit escape valve: STOP here, keep the shortcut, and
report -- do not improvise a third variant. Full diagnosis, the exact
capture, and the concrete next research question (does retail's own
transitional_insert loop check sphere_path.collide on every iteration
regardless of Phase 2's own return value, or only when Phase 2 returns OK?)
are recorded in the research doc's §7 item 6 and the doc's headline; the
campaign plan's P2 section gets a matching status note.
Physics test suite: 1841 passed, 1 skipped (D4, pre-existing/unrelated), 0
failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
[snap] now permanently wired; three instrumented fresh logins against
local ACE reproduce nothing (consistent with the Coldeve rarity). Next
recurrence self-diagnoses; matrix scenario 11 is the structured re-test.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PhysicsEngine.DiagnosticLog was never assigned in production, so the #111
[snap] apparatus (one line per entry-snap Resolve, low volume by design)
was structurally silent - including on the Coldeve run-on-the-spot login.
Wire it at session composition; a session reset constructs a fresh engine
and re-wires.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ports the retail CACQualities/EncumbranceSystem/MovementSystem chain
(named-retail decomp pc 256393/412901-414050/416169-416320/695958+) so
PlayerWeenie's run rate, jump height, jump permission, and jump stamina
cost are real functions of burden, current stamina, and vitae/skill
enchantments instead of stubs.
Core:
- New EncumbranceSystem.cs (delegates to the already-verified
BurdenMath formulas — one source of truth for the burden HUD and
movement physics) and MovementSystem.cs (GetRunRate/GetJumpHeight/
JumpStaminaCost/GetJumpPower, decomp-cited; ACE cross-referenced
where BN dropped the general-case arithmetic entirely).
- PlayerWeenie rewritten as the CACQualities-shaped composition:
CanJump gates on burden (<2.0 load, UN-8 — x87 polarity resolved by
plausibility, Ghidra MCP unavailable this slice), JumpStaminaCost
returns the real ceil((load+0.5)*power*8+2) cost and always affords
it (matches decomp — retail's own function never refuses; "weak"
jump comes entirely from the stamina==0 skill-zeroing gate inside
InqRunRate/InqJumpVelocity, not a hard refusal), SetStamina wires a
null="unknown, don't gate" sentinel preserving every pre-P1 test.
- EnchantmentMath.GetMod gained an optional StatModType flag filter
(GetSkillMod convenience wrapper) so the SAME vitae/family-stacking
machinery already used for vital-max buffs now also answers "what's
the vitae+skill-enchantment-adjusted Run/Jump skill" — reusing the
M3 active-enchantment state, not a new engine.
Runtime:
- RuntimeCharacterState now stores the pre-EnchantSkill base run/jump
skill and recomputes the adjusted value (vitae first, then matching
Skill-flagged buffs, floor 0.5, truncate) on every base push AND on
every Spellbook.EnchantmentsChanged notification — a vitae change
alone moves the produced rate without a fresh PlayerDescription.
- RuntimeMovementSkillState extended with Burden/CurrentStamina
(RuntimeMovementSkillProjection.ApplyTo pushes both through the
existing seam); LiveSessionEventRouter recomputes burden from the
same Strength+aug-property+EncumbranceVal inputs the burden HUD
already assembles (reacting to the same ClientObjectTable events)
and pushes current stamina from LocalPlayerState vital updates.
- Wires the previously dead-lettered ReportExhaustion() R3-W4 seam:
LiveSessionRuntimeFactory's OnMovementStatsUpdated callback re-
applies the current snapshot to the live controller and forces an
immediate movement re-evaluation on any skill/burden/stamina change.
Register: retires TS-5 (CanJump/JumpStaminaCost stubs) and AP-25 (no
vitae in pushed skill). Adds AP-127 (two minor unmodeled retail bonus
properties + the stamina-buff-adjusts-local-copy nuance, deliberately
out of the bounded "run/jump query path only" scope) and UN-8 (the
CanJump x87 polarity call, flagged for a future Ghidra MCP
confirmation pass). Extends TS-23 (PlayerKillerStatus not parsed) to
cover JumpStaminaCost's new pk parameter, hardcoded false pending P3.
Full pseudocode + retail citations + the vitae/skill-level finding in
docs/research/2026-07-30-stat-coupled-movement-pseudocode.md.
Release suite: Core.Tests 3977/2 skips, Runtime.Tests 425/0 skips,
App.Tests 3968/3 skips — all green. (One pre-existing, unrelated Debug-
only flake in LandblockBuildOriginTests reproduces on the pre-P1
baseline and passes in Release; not touched here.)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Campaign P Slice P2 step 3 (docs/research/2026-07-30-response-layer-edge-family-pseudocode.md
§1, §6 Step 5). The named retail decomp (CPhysicsObj::calc_friction,
pseudo-C:276694-276822, 0050ee70) independently re-confirms the 0.25f
threshold (derived twice, once per BN-rendered branch); the in-code claim
that "the decompile uses 0.0" traced to the older, unnamed FUN_0050f940
Ghidra chunk at a different address -- per CLAUDE.md the named decomp wins.
calc_friction now reads angle = dot(Velocity, GroundNormal); if (angle >=
0.25f) return; then unconditionally removes the normal-aligned velocity
component, then applies the existing (already-present but previously
unreachable) PhysicsState.Sledding-gated friction overrides. The BN-rendered
"two duplicated branches" around the state check is adopted as a single
linear function matching ACE's PhysicsObj.calc_friction shape -- the branch
split is most likely a BN decompiler artifact around one `if (state &
SLEDDING_PS)` block (ACE-derived, Ghidra-verify; low implementation risk
either way since ACE's reading is adopted regardless).
Why this doesn't repeat the reverted 2026-04-30 L.3c regression (naive 0.0
-> 0.25f bump dropped forward locomotion 3 -> 0.16 m/s): that test predates
the 2026-07-17 R6 "local player animation-owned grounded movement" landing.
PlayerMovementController (Runtime/Gameplay, out of this slice's scope) zeroes
Velocity.X/Y to exactly zero every tick before calc_friction runs whenever
animation root motion drives the walk, so friction has nothing horizontal
left to hammer on the production graphical local-player path. Pinned at the
PhysicsBody level (the only file this slice may touch) by
GroundedRootMotion_FrictionThreshold_DoesNotHammerLocomotionTests. The
headless/get_state_velocity path and remote/NPC movers still feed real
velocity into this function and remain the ones to watch if a similar
regression resurfaces there -- flagged in the retired AP-7 row for future
sessions working in Runtime/Gameplay.
Left an open, explicitly-flagged discrepancy: the raw decomp's Sledding
slope-flatness test computes cos(10 deg) (~0.984808) while ACE's port (and
acdream's prior dead code) compares GroundNormal.Z > 0.99999536f (~0.175 deg
from flat) -- physically different tests, neither confirmed this pass
(Ghidra MCP down). Kept 0.99999536f provisionally (least churn) and filed
AD-55 for just that constant rather than silently picking one.
Register: AP-7 retired with a corrected citation; AD-55 filed for the
cos(10 deg) question. Core.Tests: 3916 passed, 2 skipped (both pre-existing
and unrelated), 0 failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Campaign P Slice P2 step 1 (docs/research/2026-07-30-response-layer-edge-family-pseudocode.md
§2, §6 Step 1/2). The TS-1 register row (retail-divergence-register.md:238)
described work that is already done: SpherePath.PrecipiceSlide,
Transition.CliffSlide, and Transition.EdgeSlideAfterStepDownFailed are real,
tested ports of retail's edge_slide -> precipice_slide/cliff_slide chain
(pc:274316, pc:272397, pc:273001-273090). Its cited :1254 line was stale
stepping-loop code the file moved past.
The one real remaining gap (the back-probe fallback skipping retail's
walkable_check_pos/localspace_sphere recache before its second
precipice_slide call, pc:274318-274326 / 0050b4e0-0050b507) needed no
production code change: a fresh read of SPHEREPATH::get_walkable_pos
(0050a8f0), cache_localspace_sphere (0050c9d0), and set_walkable_check_pos
(00509ce0) shows that machinery exists to re-project a sphere across
retail's PER-CELL local coordinate frames. acdream's SpherePath.WalkableVertices
and GlobalSphere are populated in UNIFIED WORLD SPACE at assignment time
(SetWalkable/SetWalkableTransformed, SetCheckPos/RestoreCheckPos), so both
operands BSPQuery.FindCrossedEdge compares are already commensurable --
retail's recache is a no-op correction under this architecture, and
FindCrossedEdge never reads a sphere radius, so retail's walkable_scale
radius correction has no acdream counterpart either. Documented in-code at
the back-probe site with full citations, and pinned with
EdgeSlideBackProbePrecipiceSlideTests: a walkable polygon rediscovered near
GlobalCurrCenter, tested against GlobalSphere[0] restored to the original
failed target, crosses the edge and slides -- it does not wedge into
Collided (and the inverse case, standing inside the polygon with no edge
crossed, correctly still returns Collided matching retail's own
precipice_slide on a false find_crossed_edge).
TS-1's other two flagged gaps are real acdream-only compensating branches,
not retail reads, and get their own rows rather than being silently
retired alongside it:
- AD-53: CliffSlide's three-source reference-normal fallback chain
(LastWalkablePlane -> LastKnownContactPlane -> world-up) vs retail's
direct last_known_contact_plane.N use. A fresh read of
last_known_contact_plane's maintenance (pc:272659-272668) confirms retail
overwrites it unconditionally every validate_transition pass, including
with a steep plane -- so the fallback chain compensates for AP-4's
incomplete OnWalkable bookkeeping, not a retail-matching read.
- AD-54: the walkable-steepness reroute to CliffSlide before PrecipiceSlide
when the stored walkable polygon itself is steeper than FloorZ. Retail's
raw edge_slide has no such branch; the permissive LandingZ acceptance
that makes this state reachable IS retail-faithful (TS-4's own
BSPTREE::find_collisions citation), but whether retail's outer
transitional_insert retry loop absorbs the resulting COLLIDED_TS some
other way is not yet independently verified -- flagged open in the row.
Physics test suite: 1836 passed, 1 skipped (D4, unrelated to this change).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
AP-7's gate is the Sledding branch; ACE's linear calc_friction (0.25 dot
threshold, unconditional small-angle subtraction, Sledding overrides) is
the correct reading and the L.3c walking regression is architecturally
moot for the root-motion path. TS-1's register cite is stale dead code -
the PrecipiceSlide/CliffSlide/EdgeSlide chain is substantially ported
with one precise back-probe re-cache gap. #166 is a composite of
AD-25+AP-7+TS-4, not a missing Sledding auto-toggle (no client write site
exists). TS-4 removal is sequenced AFTER the TS-1 gap closes with
captured fixtures. #116 stays oracle-first. Port order + 8 open
Ghidra-verify questions recorded.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The one user stop of the campaign: each scenario names its setup, the
retail-correct outcome, and the register rows / issues it closes,
including the stale #172-#175/#41 gate reconciliation via scenario 8 and
the #167 leash check riding scenario 12.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Outbound 0xF61C requires the published movement controller, so the login
seed ran; the residual suspect is a seeded (cell,pos) pair the resolver
cannot operate on. PhysicsEngine.DiagnosticLog has no production
assignment, so the #111 [snap] lines were structurally absent from the
Coldeve log - wiring it is a P6 prerequisite.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Zero landblock loads occurred before the login recenter (worker gated
until the real spawn center), so no stale Holtburg-frame physics blocks
ever existed. Remaining: (f) login SnapToCell seed race -> NO-LANDBLOCK
verbatim resolves, (e2) CellGraph/_landblocks skew, (g) root-motion Frame
not reaching the transition. The probe run's [resolve] line pattern
discriminates all three.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Log facts: outbound MTS/AP flowed all through the run-on-spot window;
reveal collision=True is attested by the SAME _landblocks dict the
resolver walks; the 'unattributed' recenter is the default Holtburg
pre-login center -> first real position. Deduction: local display is
client-authoritative, so ACE rejection cannot pin the local body - the
defect is local zero-advance resolves. Prime suspect: login recenter may
not route through Slice E generation retirement, leaving stale
Holtburg-frame neighbor landblocks overlapping the new frame (#145
stale-offset class, neighbors were explicitly left by the 2026-06-20
center-only fix).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The 2026-06-21 residual predates its own fix: AD-30's verbatim hold +
the #145 carried anchor kill the pick march, R3-W6 StopCompletely kills
the stale arrival velocity, canonical outbound position ownership kills
the 17410 wire artifact class, and the reveal barrier holds incomplete
destinations in the tunnel. Pinned by TeleportFarTownRunawayTests
(south+east unstreamed-edge); connected evidence: 20-teleport Coldeve
session 2026-07-29 + K3/K4 portal routes. Carried-debt lists updated.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Byte-decoded GetStart/MaxConstraintDistance (0x0050ebc0/0x0050ec10) from
the PDB-paired v11.4186 binary: start = outdoor 10 m / indoor 5 m, max =
outdoor 50 m / indoor 20 m; the player-vs-remote branch is vestigial
(identical constant pairs). ACE's start mapping is inverted - do not
copy. Full SmartBox::HandleReceivedPosition 0x00453fd0 arming flow
transcribed (remote self-anchor post-MoveOrTeleport, player anchors to
received position, teleport branch zeroes velocity). TS-35 + #167 retire
together at the P5 port.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
TS-46: init_sphere 0x0050c670 seeds the Setup's own sphere list; step
heights come from setup step_up/step_down x scale (0x005180d0/f0) and the
local player already ports this (PlayerModeController.ApplyStepHeights) -
remote/ordinary 0.4f pins are a plumbing gap. AD-25: handle_all_collisions
0x00514780 is one uniform CPhysicsObj function; the remote reflect block
should swap to the existing PhysicsObjUpdate.HandleAllCollisions port.
TS-23: parse/storage/exemption machinery exists; only moverFlags call
sites read a GUID heuristic. AP-71: check_entry_restrictions 0x0052b6d0
transcribed; restriction_obj write-site field collision flagged OPEN
(Ghidra MCP unreachable). AP-10: ValidateWalkable verbatim-correct; only
the dry-corner constant collapsed, plus WATER_CONTACT_TS declared but
never written.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The issue's premise (HasOmega cleared, pi/2 fallback) was disproved by
the R6 complete-root-frame cutover: MotionTable 0x09000001 authors
omega.Z = -1.5 rad/s literally. The run turn multiplier is the verbatim
FUN_00527be0 port (RunTurnFactor = 1.5). No cdb capture needed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
User-directed pre-vendor detour from the 2026-07-29 physics audit. Goal:
Retail Movement Parity v1 - zero physics TS rows, no unargued
feel-affecting AP rows, issues #262/#165/#166/#116/#167/#72/#153 closed,
one batched connected visual matrix. Sonnet implements, Opus reviews at
slice boundaries. Roadmap gains the Campaign P entry and records Campaign
N's user-accepted closure; CLAUDE.md current-state pointer updated.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Slice 4 passed its two-client Coldeve visual gate and was user-accepted;
world-interaction program resumes at Slice 5 (vendor browsing) after the
physics parity campaign.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 23:30:09 +02:00
1786 changed files with 656844 additions and 26191 deletions
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 `
- [`docs/plans/2026-07-29-network-transport-campaign.md`](docs/plans/2026-07-29-network-transport-campaign.md) — Campaign N, the retail reliable-transport port — **CLOSED 2026-07-29, user-accepted** (#260 closed; a real wire loss recovered live during the acceptance session). Still the SSOT for the transport mechanism, the ACE constraint table, and the landmine list — read it (or `claude-memory/project_network_transport_digest.md`) before touching anything under `src/AcDream.Core.Net/`.
- [`docs/plans/2026-07-27-vulkan-campaign.md`](docs/plans/2026-07-27-vulkan-campaign.md) — Campaign V, OpenGL → Vulkan — **CLOSED 2026-07-29**; the completed record of the RHI contract, V0–V11 slices, and the GL deletion. Historical reference for `src/AcDream.App/Rendering/`.
- [`docs/ci-and-releases.md`](docs/ci-and-releases.md) — **the Gitea CI/release SSOT (2026-08-19)**: every push to main gates on two self-hosted runners (RARE-win / eriktestLinux) and publishes a Gitea Release the launcher installs from; payloads are release attachments, the `latest` release is the launcher's pointer, old releases are pruned to 5. Load-sensitive tests live in `Lane=Timing` (see `docs/release-gate.md`) — do NOT chase them individually.
- [`docs/plans/2026-05-12-milestones.md`](docs/plans/2026-05-12-milestones.md) — milestone targets + freeze list per milestone
- [`docs/plans/2026-04-11-roadmap.md`](docs/plans/2026-04-11-roadmap.md) — what's shipped, what's in flight, what's next
- [`docs/ISSUES.md`](docs/ISSUES.md) — open + recently closed bugs (tactical)
@ -1377,8 +1569,26 @@ via `PlayerMovementController.ApplyServerRunRate`) or from
- `ACDREAM_DUMP_MOTION=1` — dump every inbound `UpdateMotion` (guid,
stance, cmd, speed) + resulting `SetCycle` call. Massive for remote-
│ └── RemoteTeleportHook.cs # ordered retail teleport teardown actions (teleport_hook port; C4 4b-3 deleted RemoteTeleportController/RemoteTeleportPlacement/RemoteShadowPlacementSynchronizer — the teleport arm now routes through RuntimeRemotePlacementDriveController, the same canonical placement owner the far snap uses)
`teleport_hook` (@0x00514ED0) still runs first — `RemoteTeleportHook`,
invoked from `LiveEntityNetworkUpdateController`'s teleport-arm dispatch —
and its `report_collision_end(this, 1)` action now routes through
`RuntimeCollisionReportingState.LeaveWorld` (the existing, exact port of
that retail call) rather than `ShadowObjectRegistry.Suspend`, which ports a
DIFFERENT retail function `teleport_hook` never calls. `GpuWorldState`
performs remove+place as one spatial rebucket,
then commits and serially drains visibility edges; `LiveEntityRuntime` filters
delayed duplicates. A rollback inside an observer cannot race the outer
@ -474,7 +475,7 @@ useful ordering seam, but its ownership status is **partial**.
| Area | Status | Current truth |
|---|---|---|
| Startup options | **Complete** | `RuntimeOptions` owns startup configuration (`eda936dc`). Remaining direct environment reads are legacy runtime diagnostics, not startup configuration. |
| Network session | **Complete Runtime ownership** | `RuntimeLiveSessionController` owns the sole `WorldSession` generation and resolve/create/Connect/selection/EnterWorld/Tick/stop/reconnect/disposal transaction. Runtime route owners preserve exact inbound/outbound ordering and retryable teardown. App supplies immutable options, graphical/domain callbacks, and one borrowed inertable UI command projection—no mirrored session or reset plan (`75930787`). |
| Network session | **Complete Runtime ownership** | `LiveSessionController` owns the sole `WorldSession` generation and resolve/create/Connect/pre-world selection/EnterWorld/Tick/stop/reconnect/disposal transaction. Its `RuntimeCharacterSelectionState` owns the full active roster (including greyed entries and retained wire slots), highlight, delete confirmation, restore/delete/error state, generation/lifecycle, borrowed view, ordered deltas, and typed commands. A selector-free graphical launch pauses on that owner; explicit and headless selection retain the established fallback. Runtime route owners preserve exact inbound/outbound ordering and retryable teardown. App supplies immutable options, graphical/domain callbacks, and borrowed projections—no mirrored session, selection state, or reset plan. |
| World environment | **J6.1 complete Runtime ownership** | `RuntimeWorldEnvironmentState` owns the instance-scoped Dereth calendar, synchronized clock, weather progression/state, selected day group, AdminEnvirons state, and typed debug overrides. App converts immutable DAT sky definitions once and projects the borrowed Runtime snapshot into rendering; no process-global Region origin or second App clock/weather owner remains (`902076c0`). TS-54/TS-55 register the remaining centered UI sound and full fog/ambient/radar behavior gaps. |
| Live identity/lifetime | **J3 complete** | `RuntimeEntityObjectLifetime` owns the sole `RuntimeEntityDirectory`, live `ClientObjectTable`, direct views, and ordered entity/object stream. The directory owns canonical GUID/incarnation/local-ID identity, accepted snapshots/timestamps, parent state, operation versions, and tombstones. `LiveEntityProjectionStore` owns App graphical sidecars by exact `RuntimeEntityKey`; hydration, presentation components, `GpuWorldState` residence/visibility, and retryable teardown preserve that key without another authority. Exact receipts precede fallible callbacks, re-entrant commits drain synchronously in sequence, and stable reset/disposal must converge the complete ledger to zero (`f46ddb5c`, `420e5eea`, `e937cc36`, `5ef8b537`, `ce3ac310`, `119b7c11`). |
| Inbound/object-frame order | **Complete App orchestration** | `UpdateFrameOrchestrator` owns the complete typed host phase graph; `RetailInboundEventDispatcher`, `RetailLiveFrameCoordinator`, `LiveObjectFrameController`, `LiveSpatialPresentationReconciler`, streaming/input/teleport/player-mode/camera owners preserve the accepted order. `GameWindow.OnUpdate` is one profiler-scoped handoff (`e91f3102`). |
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.
**Status:** Living document. Updated 2026-07-27. **M3 landed; M4 is active.** M3's retail casting/UI, R6 locomotion/collision/projectile/teleport/radar rebaseline, deterministic fresh-login/portal world lifecycle, and final two-client portal observer flow are user-gated. All eight slices of the behavior-preserving ownership campaign in [`docs/architecture/code-structure.md`](../architecture/code-structure.md), their automated closeout, and the user's connected visual matrix are complete. Modern Runtime J3 canonical entity/object lifetime and J4 gameplay-state ownership are closed at `89e6b207`; J5.1 canonical selection/combat/target-mode ownership is closed at `b298f99f`, J5.2 interaction transactions at `f5f7b417`, J5.3 combat/magic intent at `20df9d15`, J5.4 local movement/outbound cadence at `aa3f4a60`, J5.5 per-session physics/remote simulation at `7e6033d0`, J5.6 projectile simulation at `2aee3356`, and J5.7 combined simulation closeout at `cdee7a4b`. J6.1 world-environment ownership is closed at `902076c0`; J6.2 canonical reveal generation and typed destination readiness is closed at `a6860d55` plus `acb845d8`; J6.3 exact F751/Position destination correlation is closed at `6a063a27`; J6.4 exact graphical-host acknowledgement and owner cleanup is closed at `18d17d8b`. J7's one graphical `GameRuntime` root is closed at `ce41efb9`, including the user's 2026-07-27 exact post-cutover visual acceptance. J8 closed Slice J at `a9a822f2` with one shared graphical/no-window root and generation-reset transaction. Slice K Linux headless/multi-session work is closed. K0's tested no-presentation Windows/Linux boundary closed at `aada8a37`, K1's portable single-session host at `f8cb840f`, K2's deterministic scheduler and shared bot API at `7e8acb74` plus `38e83640`, and K3's shared-content/isolation plus connected observer gate at `3f340125`. K4 closed through `776482da`: 1/5/10/30-root isolation and two-hour simulated endurance, death/randomized cancellation, committed resource ceilings, ten minutes of exact native Linux two-account connected sampling, ACE-confirmed graceful logout, and zero-debt Runtime/content convergence all pass. Slice L Linux graphical/platform work is parked at its L1 implementation checkpoint by user direction on 2026-07-27. Issue #225's lifestone/particle alpha comparison remains a separate rendering visual gate.
**Status:** Living document. Updated 2026-08-14. **M3 landed; M4 is active.** M3's retail casting/UI, R6 locomotion/collision/projectile/teleport/radar rebaseline, deterministic fresh-login/portal world lifecycle, and final two-client portal observer flow are user-gated. All eight slices of the behavior-preserving ownership campaign in [`docs/architecture/code-structure.md`](../architecture/code-structure.md), their automated closeout, and the user's connected visual matrix are complete. Modern Runtime J3 canonical entity/object lifetime and J4 gameplay-state ownership are closed at `89e6b207`; J5.1 canonical selection/combat/target-mode ownership is closed at `b298f99f`, J5.2 interaction transactions at `f5f7b417`, J5.3 combat/magic intent at `20df9d15`, J5.4 local movement/outbound cadence at `aa3f4a60`, J5.5 per-session physics/remote simulation at `7e6033d0`, J5.6 projectile simulation at `2aee3356`, and J5.7 combined simulation closeout at `cdee7a4b`. J6.1 world-environment ownership is closed at `902076c0`; J6.2 canonical reveal generation and typed destination readiness is closed at `a6860d55` plus `acb845d8`; J6.3 exact F751/Position destination correlation is closed at `6a063a27`; J6.4 exact graphical-host acknowledgement and owner cleanup is closed at `18d17d8b`. J7's one graphical `GameRuntime` root is closed at `ce41efb9`, including the user's 2026-07-27 exact post-cutover visual acceptance. J8 closed Slice J at `a9a822f2` with one shared graphical/no-window root and generation-reset transaction. Slice K Linux headless/multi-session work is closed. K0's tested no-presentation Windows/Linux boundary closed at `aada8a37`, K1's portable single-session host at `f8cb840f`, K2's deterministic scheduler and shared bot API at `7e8acb74` plus `38e83640`, and K3's shared-content/isolation plus connected observer gate at `3f340125`. K4 closed through `776482da`: 1/5/10/30-root isolation and two-hour simulated endurance, death/randomized cancellation, committed resource ceilings, ten minutes of exact native Linux two-account connected sampling, ACE-confirmed graceful logout, and zero-debt Runtime/content convergence all pass. Slice L Linux graphical/platform work is parked at its L1 implementation checkpoint by user direction on 2026-07-27. Issue #225's lifestone/particle alpha comparison remains a separate rendering visual gate.
**Purpose:** One source of truth for where the project is and where it's going. Every observed defect or missing feature has a named phase that owns it; when something looks wrong in-game, look here to find the phase that'll address it. Implementation details live in per-phase specs under `docs/superpowers/specs/`, not in this file.
**Slice L checkpoint:** L0 closed at `66f114b2` with one typed graphical
@ -22,8 +22,9 @@ falsification ledger — is [`2026-07-27-vulkan-campaign.md`](2026-07-27-vulkan-
The user signed the V10 cutover; V11 deleted GL (−27,670 lines) and all
deferred reruns pass on the GL-free tree.
**Campaign N — retail reliable network transport (ACTIVE, started
2026-07-29):** the #260 live-server wedge root-caused to missing packet-loss
**Campaign N — retail reliable network transport (CLOSED 2026-07-29,
user-accepted; #260 closed — a real wire loss recovered live during the
acceptance session):** the #260 live-server wedge root-caused to missing packet-loss
recovery in both directions (no outbound retransmission; inbound ISAAC burned
in arrival order) — acdream could not survive a single lost UDP packet, and
loopback gates were structurally blind to it. The campaign ports retail's
@ -34,9 +35,138 @@ N0–N6 with a permanent loss-injection gate at N5 and a user Coldeve
endurance session as final acceptance. The plan is
| E.2 | Audio engine — OpenAL 16-voice 3D pool with retail-faithful quieter-slot eviction, SoundTable cookbook (probability-weighted variant picking), Wave PCM decoder, AudioHookSink wiring | Tests ✓ |
| E.2 | Audio engine — OpenAL voice bank, SoundTable/Wave decoding, AudioHookSink wiring. **Superseded 2026-08-08 by Campaign A** (`docs/plans/2026-08-08-audio-parity-campaign.md`), which found three of this row's claims false: the pool was not 3-D in retail (every gameplay buffer is 2-D and spatialization is CPU-side), eviction compared gain rather than retail's DAT priority, and the "probability-weighted variant picking" was the defect itself — probability is a Bernoulli silence gate, not a weight. Campaign A also added the 0xF750 server sound channel, the interface sound bank, and the region ambient system, and deleted the music API (retail has none). | Tests ✓ / listening gate owed |
| E.3 | Particle system (data layer) — ParticleSystem with 13 motion integrators, EmitterDescRegistry, ParticleHookSink wiring all CreateParticle / DestroyParticle / StopParticle hooks | Tests ✓ |
| I.3 | `LiveCommandBus` + `WorldSession.SendTalk` / `SendTell` / `SendChannel` — replaces `NullCommandBus.Instance` with a real handler-registry `ICommandBus`. New `SendChatCmd` record + `ChannelResolver` legacy-id mapping (per holtburger). 3-line wrappers around existing `ChatRequests.BuildTalk/Tell/ChatChannel`. | Tests ✓ |
| I.4 | `ChatPanel` input field + slash commands — Enter-to-submit input field; `ChatInputParser` recognises `/say``/t``/tell``/r``/g``/f``/a``/m``/p``/v``/cv``/lfg``/trade``/role``/society``/olthoi`; `ChatVM.LastIncomingTellSender` tracks for `/r` reply. `ImGui.WantCaptureKeyboard` already suppresses WASD on focus. | Live ✓ |
| I.5 | Holtburger inbound chat parity + Windows-1252 codec — `EmoteText (0x01E0)`, `SoulEmote (0x01E2)`, `ServerMessage (0xF7E0)`, `PlayerKilled (0x019E)` parsers + `WeenieError` routing through `GameEventWiring`. Global string codec switch from `Encoding.ASCII` to `Encoding.GetEncoding(1252)` so accented names round-trip per retail + holtburger. | Tests ✓ |
| I.6 | TurbineChat codec + `ChatChannelInfo` — full `0xF7DE` codec with three payload variants (`EventSendToRoom`, `RequestSendToRoomById`, `Response`), UTF-16LE strings with variable-length prefix, `SetTurbineChatChannels (0x0295)` parser, unified `ChatChannelInfo` (Legacy + Turbine variants), `TurbineChatState`. **ACE doesn't host a TurbineChat server — codec is ready when retail-emulating servers exist.** | Tests ✓ |
| I.6 | TurbineChat codec + `ChatChannelInfo` — full `0xF7DE` codec with three payload variants (`EventSendToRoom`, `RequestSendToRoomById`, `Response`), UTF-16LE strings with variable-length prefix, `SetTurbineChatChannels (0x0295)` parser, unified `ChatChannelInfo` (Legacy + Turbine variants), `TurbineChatState`. **Correction (Campaign CH slice CH3, 2026-08-09): the "ACE doesn't host a TurbineChat server" note above was FALSE — ACE has a complete, on-by-default TurbineChat implementation; see `docs/research/2026-08-09-chat-side-channels-vs-ace.md` §1.** | Tests ✓ |
| I.7 | `CombatChatTranslator` — retail-faithful combat-text formatters into `ChatLog` ("You hit drudge for 50 slashing damage (87%)"). Subscribes to `CombatState`'s `DamageTaken` / `DamageDealtAccepted` / `EvadedIncoming` / `MissedOutgoing` / `KillLanded`; `AttackDone` is control-only and deliberately silent. | Tests ✓ |
| K | Input architecture — `Action` enum, `KeyChord`, `KeyBindings`, multicast `InputDispatcher` with scope-stack + modal capture, retail-default keymap (152 bindings), `keybinds.json` persistence, F11 Settings panel with click-to-rebind + conflict detection, main menu bar + View menu | Live ✓ |
| L.0 | Full retail-style Settings interface — F11 tabbed panel with 6 tabs (Keybinds + Display + Audio + Gameplay + Chat + Character). `settings.json` at `%LOCALAPPDATA%\acdream\`, per-toon `Character` keying (swapped on EnterWorld). Display GL knobs (Resolution / Fullscreen / VSync / FOV / ShowFps) + Audio (Master / SFX) live-wired; Gameplay / Chat / Character settings persist for server-sync wiring later. Tab API extension to `IPanelRenderer`; chat Copy mode (read-only multi-line); per-panel layout reset; FramebufferResize handler keeps GL viewport + camera aspect + panel positions in sync. | Live ✓ |
@ -869,7 +999,7 @@ the way retail + holtburger expect.
- **✓ SHIPPED — I.3 — `LiveCommandBus` + `WorldSession.Send{Talk,Tell,Channel}`.** Replaces `NullCommandBus.Instance` with a real handler-registry `ICommandBus`. New `SendChatCmd` record + `ChatChannelKind` enum + `ChannelResolver` legacy-id mapping (per holtburger). `WorldSession.SendTalk` / `SendTell` / `SendChannel` are 3-line wrappers around existing `ChatRequests.BuildTalk/Tell/ChatChannel`. Commit `8e6e5a0`.
- **✓ SHIPPED — I.4 — `ChatPanel` input field + slash commands.** Enter-to-submit input field on `ChatPanel`; `ChatInputParser` recognises `/say``/t``/tell``/r``/g``/f``/a``/m``/p``/v``/cv``/lfg``/trade``/role``/society``/olthoi`; `ChatVM.LastIncomingTellSender` tracks for `/r` reply. `ImGui.WantCaptureKeyboard` already suppresses WASD on input focus. Commit `f14296c`.
- **✓ SHIPPED — I.5 — Holtburger inbound chat parity + Windows-1252.** `EmoteText (0x01E0)`, `SoulEmote (0x01E2)`, `ServerMessage (0xF7E0)`, `PlayerKilled (0x019E)` parsers + `WeenieError` routing through `GameEventWiring`. Global string codec switch from `Encoding.ASCII` to `Encoding.GetEncoding(1252)` so accented names round-trip per retail + holtburger. Commit `ff5ed9e`.
- **✓ SHIPPED — I.6 — TurbineChat codec + `ChatChannelInfo`.** Full `0xF7DE` codec with three payload variants (`EventSendToRoom`, `RequestSendToRoomById`, `Response`), UTF-16LE strings with variable-length prefix, `SetTurbineChatChannels (0x0295)` parser, unified `ChatChannelInfo` (Legacy + Turbine variants), `TurbineChatState`. **ACE doesn't host a TurbineChat server — codec is ready when retail-emulating servers exist.** Commit `ca968fc`.
- **✓ SHIPPED — I.6 — TurbineChat codec + `ChatChannelInfo`.** Full `0xF7DE` codec with three payload variants (`EventSendToRoom`, `RequestSendToRoomById`, `Response`), UTF-16LE strings with variable-length prefix, `SetTurbineChatChannels (0x0295)` parser, unified `ChatChannelInfo` (Legacy + Turbine variants), `TurbineChatState`. **Correction (Campaign CH slice CH3, 2026-08-09): the "ACE doesn't host a TurbineChat server" note above was FALSE — ACE has a complete, on-by-default TurbineChat implementation; see `docs/research/2026-08-09-chat-side-channels-vs-ace.md` §1.** Commit `ca968fc`.
- **✓ SHIPPED — I.7 — `CombatChatTranslator`.** Retail-faithful combat-text formatters into `ChatLog` ("You hit drudge for 50 slashing damage (87%)"). Subscribes to visible damage/evasion/miss/kill events; `AttackDone` was removed from chat after named retail + ACE proved its nonzero final status is control-only. Commit `3d26c8e`, corrected 2026-07-11.
- **✓ SHIPPED — I.8 — Docs alignment.** Roadmap (this file) + `docs/ISSUES.md` issues #14-#20 closed + `memory/project_chat_pipeline.md` crib + `MEMORY.md` index entry + `CLAUDE.md` UI strategy paragraph all updated to reflect Phase I shipped state. Commit `(this commit)`.
@ -2001,6 +2131,7 @@ OpenGL ceiling; revisit macOS only if a supported graphics backend is chosen.
| 1 | Burdened movement | Load the character past 100% burden (pack full of heavy loot), then ~190% | Run speed visibly drops past 100%; near 200% the character can barely move and jumps only inches | TS-5/AP-25 retirement (P1) |
| 2 | Exhausted jump | Drain stamina (repeated full-power jumps) to near 0 | Jump cost rises with burden; at insufficient stamina the jump refuses/weakens exactly like retail (no infinite full-height jumps) | TS-5 retirement, ReportExhaustion consumer (P1) |
| 3 | Vitae run | Die once, recover the corpse with vitae active | Run/jump measurably below the no-vitae baseline; recovers as vitae expires | AP-25 replacement (P1) |
| 4 | Cliff edge | Walk (not jump) off a steep cliff/roof edge (Holtburg bluffs) | The body slides along/over the edge (PrecipiceSlide), never a dead stop pinned at the lip | TS-1 retirement (P2) |
| 5 | Downhill sled | Run-jump down a long slope and land | Landing glides ("sleds") with a small bounce, then friction settles it; no instant stick | #166 close, AP-7 gate (P2) |
| 6 | Wall graze | Run into a wall at a very shallow angle; also press into a corner and wiggle | Tiny lateral slide is preserved (no dead-stop absorb); corner shuffle-out works | #116 close (P2) |
| 7 | Crowd regression | Stand in a packed monster camp; wiggle, jump out | Spacing and shuffle-out unchanged from the accepted #182/#184 baseline | P2/P3 regression guard |
| 8 | Two-client remote checks | Second client (retail or acdream) observed from the first | (a) remote stops at walls without sinking in (#165); (b) remote jumping into a dungeon ceiling bounces down immediately (#173); (c) Holtburg town-network portal platform steps up (#172); (d) door Use works after jumping (#174); (e) closed-door collision matches the visual door (#175); (f) no sub-decimeter blips on observed players (#41) | #165 close + the stale #172–#175/#41 gate reconciliation |
| 9 | Barred house | Approach a house/cell the character is not a guest of | Blocked at the threshold client-side (no enter-then-server-boot) | AP-71 port (P4) |
| 10 | Wading | Walk into shallow water at a shoreline | Feet sink ~0.1 m into the water surface like retail; movement feel unchanged | AP-10 restore (P4) |
| 11 | Fresh logins ×20 | 20 fresh-process logins (mix of outdoor/indoor saves) | Zero run-on-the-spot; movement immediate every time | #262 close (P6) |
| 12 | General sweep | 10 min free play: walk/run/strafe/turn/jump/stairs/doors/portals incl. one far-town hop | Indistinguishable from the accepted R6 baseline; no new regressions | campaign regression gate; #153 connected confirmation |
Rubber-band check (rides scenario 12): induce a server correction (e.g.
brief packet-loss on Coldeve or a forced position reset) — the leash taper
engages and jumping inside a tight leash is refused (0x47), per the #167
port (P5).
**Recording the result:** per scenario PASS/FAIL + a one-line note. Any FAIL
reopens its slice; the campaign closes only on a clean sheet. On full pass:
close #165/#166/#116/#262 (if not already), mark #172–#175/#41 reconciled
with this matrix as the cited gate, update the campaign plan + roadmap +
world-audio quiescence across portal transitions, AL buffer budget/lifetime.
Divergent or missing, ranked by audible impact:
| # | Defect | Where | Symptom |
|---|---|---|---|
| 1 | Probability gate absent: `SoundCookbook.Roll` short-circuits single-entry lists (4,183/4,184 entries!) before any roll; CDF walk instead of `(n−1)` pick + gate | `SoundCookbook.cs` | Idle chatter ~20× too often; nothing ever randomly silent — the "incorrect ambient-ish noise" complaint |
| 2 | 0xF750 unhandled — zero hits in `src/` | `Core.Net` routing | Every server cue silent (hits, wounds, pickup, locks, lifestone…) |
| 3 | Falloff: AL `InverseDistanceClamped` ref 2 m ⇒ `2/d` first-power, no −50 dB cutoff; AL's 3D panner instead of retail's ±15 dB angular pan | engine + `WorldRenderFrameBuilder` | Wrong loudness curve in both directions — quieter than retail up close, audible where retail is silent; stereo image wider and 3-D where retail's is a narrow angular pan (AP-28) |
| 4 | Priority float [0,1] cast to int 0..7 → 4,100 entries collapse to 0; eviction compares gain not priority | `AudioModel`/engine | Eviction ordering gutted under voice pressure |
| 5 | Volume clamped at field instead of after distance divide | `AudioHookSink` | >1-gain sounds lose up to 3× audible range |
| 6 | Region ambient system absent (`StartAmbient` stub) | engine | Silent outdoors atmosphere (TS-29 half) |
| A2 | **COMPLETE** 2026-08-08 | `e42b9948` | 118 Core audio tests (mixer + voice pool + cookbook); full Release suite 11,639 passed / 4 skipped / 0 failed. Opus review run and applied — 2 HIGH (pan-law saturation, stale `FUN_00550ad0` header), 5 MEDIUM (untested clamp order / pan truncation / voice pool, dead `PlayingGain`, duplicated heading helper), 5 LOW. Retires AP-28; files AP-173, AP-174, TS-64, TS-65. **Owed: user listening gate.** |
| A3 | **COMPLETE** 2026-08-08 | `8bc458fb` | 14 wire-conformance tests + 5 controller tests; full Release suite 11,658 passed / 4 skipped / 0 failed. **Owed: connected gate** (melee hit / pickup / lifestone audible against ACE). |
| A4 | **COMPLETE** 2026-08-08 | `6eaa490b` | UI bank DID resolved from the dats (`0x2000004B`, content-verified: exactly the 32 `UI_*` slots) + 21-case environ table, 30 new Core tests; full Release suite 11,691 passed / 4 skipped. Retires TS-54; narrows AP-115 to notice-only. **Owed: connected gate** (`@environs` thunder + recall cues audible). **Suite note:** two load-dependent measurement flakes were observed on separate full-suite runs (`RuntimeCollisionReportingStateTests.WarmedSteadyContactRefreshDoesNotAllocate`, and one unnamed Core.Net test); both pass in isolation and neither touches audio. |
| A5 | **COMPLETE** 2026-08-08 | `7c4dd1ad` | 46 ambient conformance tests; full Release suite 11,739 passed / 4 skipped. Opus review run and applied — it caught a FATAL frame bug (cell offsets built in absolute world coordinates while the listener is in the streamed frame: every contribution culled at ~32 km, feature silent with no error), a per-entry vs per-cell denominator error that would have pushed multi-entry beds under the audibility floor, an infinite loop on a zero play-rate, and newly-audible ambients not firing until a full period later. Also moved beds onto retail's single 16-voice priority pool and made the in-block direction test XY-only. Retires TS-29; files TS-66 (`seen_outside` interiors), TS-67 (in-plane weight). **Owed: user listening gate.** |
| A6 | **COMPLETE** 2026-08-08 | `dd2cb92b` | Full Release suite 11,739 passed / 4 skipped. Deleted the music API (`PlayMusic`/`StopMusic`/`MusicVolume` + the `AudioSettings.Music` knob); exposed the Ambient slider now that A5 drives it; reset the invented 0.8 ambient default to retail's 1.0; SUPERSEDED banner on `r05-audio-sound.md` listing its five wrong sections; TS-9 re-scoped to the measured one-wave blast radius. **Deferred:**#321's decode-dedup race (a pre-existing concurrency flake, not audio-parity behaviour — not fixed speculatively without reproducing it). |
---
## CAMPAIGN CLOSEOUT (code-complete 2026-08-08)
Six slices, six commits, `c69b3bde` → A6. The full Release suite ends at
**11,739 passed / 4 skipped / 0 failed**, up from 11,563 at campaign start;
the audio subsystem went from 42 tests (one of which pinned the wrong model)
to ~215 conformance tests written against byte-decoded values.
**What was wrong, and is now right:**
| Was | Now |
|---|---|
| Probability treated as a selection weight, and skipped entirely for the 4,183/4,184 single-entry sounds | Retail's uniform `(n−1)` pick plus an independent Bernoulli silence gate |
| OpenAL 3-D spatialization, `2/d` falloff, no cutoff | Retail's CPU 2-D model: `25·vol/d²` past a 5 m knee, −50 dB no-allocate floor (~94 m), ±15 dB sine pan with a 5 m dead zone |
| Voices evicted by gain | Evicted by DAT-authored float priority, strictly-less, ring order |
| `0xF750` unparsed — every server cue silent | Parsed, guid-queued-and-replayed, played at the wire volume |
| No interface sound bank | Bank DID resolved from the dats' EnumIDMap chain; portal cues + 21 AdminEnvirons stingers live |
| No ambient system at all | Region-authored, per-land-cell, 3×3 ring, deadline-queue one-shots with terrain crossfade |
| A music API | Deleted — retail has no music system |
**Process notes worth carrying forward:**
1. **Binary Ninja could not be trusted anywhere in this subsystem.** Five
float compares render inverted or with elided constants; `GetAttenuation`
prints `* 0f`, which ports as silence at every distance. Every load-bearing
value here came from byte-decoding the PDB-paired binary. The lane notes
record the verified values; a future reader should decode rather than
re-read the pseudo-C.
2. **Two research notes were wrong and were corrected in place** — lane 1's
30 m decibel row (contradicted its own gain column) and lane 5's
transposed `GetByEnum` arguments (which would have made the UI bank
unresolvable). Both were caught by recomputing rather than copying.
3. **The reviews earned their cost.** A2's review caught a pan mapping that
saturated to full separation where retail gives 15 dB. A5's caught a
coordinate-frame error that would have made the entire ambient system
silent with nothing logged — the tests passed, the build was green, and it
would have failed only at the listening gate.
4. **An architecture guard caught a design error the tests could not**:
`ExtractedUpdateOwners_DoNotRetainAnonymousCallbacks` rejected an
`Action<float>` frame hook and forced the typed `IAmbientFramePhase`.
**A4 correction (2026-08-08, from a user question):** the enter cue was hung on the
`EnterTunnel` event — the first tunnel-family frame — instead of the sequencer's
`PlayEnterSound`, which is `Begin()` and is what retail's
`BeginTeleportAnimation` @ `0x004D638E` plays. That delayed it by a whole
TunnelFadeIn. Both cues now fire on the sequencer's own dedicated sound events
(`PlayEnterSound` had been emitted and dropped by every consumer since R6), and
`PortalCues_FireOnTheSequencersOwnSoundEvents_NotOnTheTunnelVisuals` pins the
| OP7 | CLOSED | `09cb548a` → fixes in `7b60e71b` (shared commit, see its message) | combined-lens APPROVE-WITH-FIXES (`2026-08-11-op7-review.md`); all nine findings closed | live bot-vs-ACE gate PASSED 2026-08-11 (coordinator; evidence in script §OP7) |
| OP8 | CODE-COMPLETE, gate READY | `b4edee97` (REJECTED) → rework `b1968ce9` → residuals `f1d50207` → merge `1c5cd969` | dual REJECT (`2026-08-11-op8-review-{mechanism,blast}.md`) → rework → re-review REOPEN-narrow (`2026-08-11-op8-rereview.md`) → coordinator round-2 residuals (inert-row conflict exclusion, DAT-default display, injectivity pin; #373 filed) | connected gate OWED (script §OP8) — merged onto the campaign tip AFTER `057d8cd7` per the re-review's merge note, so the #372 viewport fix covers OP8's six ListBoxes |
| OP9 | CLOSED | `371197a3` → review residuals `07f2b3f7` | combined-lens APPROVE-WITH-FIXES (`2026-08-11-op9-review.md`, `289bf5bc`): MUST-FIX 1 (SaveAudio→ApplyAudio live-apply lost its only assertion — restored + failure-ordering pin), SF-2 (code-structure.md seam list), SF-3 (dead residues: private SaveCharacter, ISettingsStorage.SaveCharacter, IngressShutdownRoots.Settings — deleted; SettingsStore's public SaveCharacter kept as the tested storage API), SF-4 (test-delta was −84 = 94 removed/10 added, not the commit message's "−80 exactly"; no live-behavior test in the gap besides MUST-FIX 1's), SF-5 (dangling comment), NIT 6 (AP-196 channel attribution corrected in-register) — all closed by coordinator residuals | closeout gate = the user's final connected matrix over §OP3–§OP8 |
with Width/Height legitimately 0 on disk. `SurfaceDecoder` had no JPEG
case AND a non-positive-dimension guard, so it fell silently to the
magenta placeholder; the listbox/ENTER fills are transparent, so one
broken background bled through as three symptoms. Fixed via
StbImageSharp (managed, Linux-safe; codec-library substitution per the
BCnEncoder precedent — no register row). BOTH silent traps now log once
per id (id-resolves-but-undecodable in `SurfaceDecoder`;
id-missing-from-DATs in `TextureCache`) — the existing magenta guard
only covered id-0. New installed-DAT sweep asserts every char-select
media id decodes non-magenta. Full suite 14,034 green.
Session-orchestration facts this round: the machine gained PowerShell 7
(winget, user-approved — the LA fixture tooling hard-requires it); an
orphan feed server from the earlier session held port 43119 with stale
fixture data (stopped); the launcher self-update bootstrap restart on a
dev binary is EXPECTED (staged launcher update → exit → respawn).
Observations still open for this round: the duplicated "versioned client
is unavailable" status line (cosmetic), and verifying Create Character is
disabled on the live screen.
## Ledger
| Slice | Status | Commits | Review | Notes |
|---|---|---|---|---|
| LA0 | **DONE 2026-08-14** | `cb6502c8`, `a49e92df` | Opus dual-lens PASS; all 6 findings CLOSED in narrow re-review | Byte-identity proven; Linux CI lanes restored; Platform BCL-only self-guard added; K0 guard untouched |
| LA1 | **DONE 2026-08-14** | `db9ad53c` (mixed — see `e1322a06`), `75a6724d` (recovery WIP), `d511e4c3`, ledger `890cf267` | Initial review FIX FIRST; F1–F8 CLOSED; narrow dual-lens re-review PASS | Release build green (0 errors / 18 warnings). Windows: Runtime 1634 / Headless 127 / App 5038+3skip. WSL: Runtime 1634 / Headless 127. Known mid-play silent-wire-drop limitation recorded above. The LA1+LA3 composer-to-both-hosts contract gate and portable CI lane landed at `8a03a25f`. |
| LA2 | **DONE + MERGED 2026-08-14** | `c6019424` (recovery WIP), `000ea979`, `1c5e66c0`, merge `e01b2cd1` | Dual-lens review FIX FIRST; all 3 findings CLOSED; final narrow re-review PASS | Probe success requires a reported roster and remains before selection/EnterWorld; terminal status derives from the actual start outcome; conditional fields distinguish omission from explicit null without weakening strict JSON. Branch gates: Runtime 1,632/1,632 and Headless 149/149 on both Windows and Ubuntu/WSL. Integrated gates: Release solution build green; Windows Runtime 1,636/1,636, Headless 151/151, App 5,039+3 skip, Launcher.Core 114/114; WSL Runtime 1,636/1,636, Headless 151/151, Launcher.Core 114/114. Repeated live ACE probe remains the LA11 user gate. |
| LA3 | **DONE + MERGED 2026-08-14** | `37d74e44`, `26feba81`, `347a1a5d`, merge `7749545d`, seam `8a03a25f` | Initial 12 findings CLOSED; four-gap narrow review FIX FIRST; final narrow re-review PASS | `AcDream.Launcher.Core` remains BCL + Platform only. Windows/WSL Core 114/114; full Release build green. Composer output is parsed by BOTH real host loaders from one linked fixture; Launcher.Core build/tests run in the portable Windows+Ubuntu lane. Windows graceful-stop gap remains tracked as #397. |
| LA4 | **DONE + MERGED 2026-08-14** | `d0a9c65d`, `10a712d6`, `ae2cbbee`, merge `60f62799` | Initial dual-lens review found 10 issues; fix re-review left one Linux execute-bit gap; final narrow re-review PASS | Avalonia 12.1.1 launcher remains thin over one BCL-only Core orchestrator. Windows/WSL Launcher.Core 162/162 and Launcher 17/17. Native `linux-x64` publish evaluates self-contained + single-file, runs without a discoverable runtime, and CI verifies executable launcher/App/Headless artifacts. LA9/LA10 bodies and LA11 visual/accessibility confirmation remain intentionally later. |
| LA5 | **DONE + MERGED 2026-08-14** | `95f4be94`, `fbe9c8a2`, `f820eb25`, merge `5535d0ad` | Initial review found 5 issues; first narrow re-review found 4 ownership/race gaps; final narrow re-review PASS | Both hosts share exact absent/null=`all`, `[]`=`none` allow-listing; transactional scoped UI/entity/selection rollback precedes unload; graphical/headless status and teardown ordering match; headless replay is exact-once under Runtime's borrowed membership lease. Branch complete suite 13,679+4 skip; portable WSL closure green. |
| LA6 | **DONE + MERGED 2026-08-14** | `41b15efd`, `259f0e5a`, merge `2bb8ccb6` | Dual-lens/CH regression review found one Headless wire-parity gap; narrow re-review PASS | Runtime owns the sole parser/router/catalog and shared four-route live binding. Both hosts run generation-scoped login commands after world entry with strict monotonic delay and nonterminal v1 failure status. Headless permit/chat/notell semantics match App. Branch complete suite 13,787+4 skip; WSL Runtime 1,662, Headless 165, Launcher.Core 167, UI/chat 922. |
| LA7 | **DONE + MERGED 2026-08-14** | LA7a `6a32f375`, `4338b1c1`, `0c8643a7`, merge `fa2de1c4`; LA7b `0e82cbf7`, `1b9e7e41`, `ff406562`, merge `7691cf75` | LA7a retail-lens PASS; LA7b review found 4 issues, first narrow pass left one restore/delete interleave, final narrow re-review PASS; AD-97 filed | Runtime owns the sole generation-scoped pre-world selection graph. Exact retail roster/grey/button/delete/restore behavior and queue routing are preserved; `NumErrors` is a sentinel, paused selection retains reliable transport sweeping, silent restore cannot block, and App has no mirror. Windows Runtime 1,653, Core.Net 958, App 5,042+3 skip; WSL Runtime/Core.Net green. |
| LA8 | **DONE + MERGED 2026-08-14** | `6cfab727`, `aeac874d`, `1dd5706e`, merge `fe63ce18` | Initial retail/architecture review found 4 issues; first narrow re-review left 2 retry-transaction/order gaps; final narrow re-review PASS | Installed DAT enum table 5 proves `0x10000005 -> 0x21000004`, root `0x1000039A`, exact flat list/buttons/templates/dialog assets, and no viewport. Runtime remains the only selection owner; row sizing, modal priority/retry, restore ordering, reset/disposal, and explicit live-DAT skip/probe are covered. Branch full suite 13,796+5 skip; LA11 owns physical visual/live-ACE acceptance. |
| LA9 | **DONE + MERGED 2026-08-14** | `ff6ebb6a`, `3f688951`, `208a70ac`, merge `2198a0cc` | Initial integrity review found 5 issues; narrow re-review left one orphan-child publication race; final narrow re-review PASS | First-run installer validates four DATs, consumes strict v1 Bake JSONL, preserves/reverifies SHA+size+tool-version records, and co-publishes self-contained launcher+Bake. Cross-process install/publish locks plus durable nonce prevent post-recovery mutation across real parent-only hard kills on Windows/Linux. Branch full suite 13,799+4 skip; real retail-DAT bake remains LA11. |
| LA10 | **DONE + MERGED 2026-08-14** | `2d2a5b50`, `1955ca8a`, `09d84387`, merge `da4fb3de` | Initial architecture/security review found 10 crash, trust, integrity, cleanup, and lifecycle issues; first narrow re-review left one rollback-source P1; final narrow re-review PASS | Production feeds and redirects are HTTPS-only, fixture loopback trust is explicit, downloads and archives are bounded and verified, version activation and rollback are atomic, active sessions hold the cross-process update lease, and schema-v3 self-update recovery verifies every prior/replacement file before apply, rollback, or restart. Real Windows/Linux process tests cover kill boundaries, staging races, lease deferral, corrupt backups, junctions/symlinks, and fail-closed recovery. Branch gates: Core 302/302 and Launcher 29/29 on Windows/WSL, full Release 13,945+4 skip, win/linux self-contained publishes. Integrated LA0–LA10 gate: 13,972+5 skip. |
| LA11 | **AUTOMATED CLOSEOUT REVIEW-CLOSED + MERGED 2026-08-15 — USER GATE PENDING** | `f881e5b4`, `134edabe`, `accd01a0`, `9f9c1167`, merge `d39f3098` | Initial dual-lens review found 7 startup/evidence/safety issues; first narrow re-review left 2 PID-reuse/ZIP-mode gaps; final narrow re-review PASS | Strict isolated roots and process-local feed override compose one exact launcher path graph. Windows targeted CTRL_BREAK is group-isolated and preserves stdin; exact-PID/start-identity status validation, credential-value scanning, deterministic Unix-mode A/B fixtures, Windows/native-Linux helper safety, and the exact A–I operator script are implemented. Clean branch preflight passed 32/32 with 13,985 tests + 4 skips. Integrated clean-head preflight at `a22f5411` passed 32/32 with 14,012 tests + 5 skips and report SHA-256 `49f225bc6043b9256f17b7bf0f29df919c894b8355633077751fd279756470df`. No connected/UI/real-DAT row has run; campaign shipment and #397 closure remain pending the user gate. |
Prepare acdream for a responsible public release and for maintenance by human
developers who do not have access to prior AI conversations, private worktrees,
or campaign memory.
The campaign succeeds when a new maintainer can clone the repository, identify
the current architecture and supported release, reproduce the build and test
gate, understand why non-obvious retail behavior exists, and publish or roll
back an authenticated release using repository-owned instructions.
This is a stabilization program, not a rewrite. The existing Runtime/App/
Headless architecture remains the foundation unless a slice proves a specific
boundary is wrong.
## 2. Binding principles
1. **Protect behavior before cleanup.** Establish a deterministic complete gate
before broad refactors, comment cleanup, or file decomposition.
2. **Distill knowledge; do not erase it.** No note, comment, issue history,
diagnostic, capture, or raw artifact is removed until its durable value has
a verified destination.
3. **One current truth.** Stable architecture and release state must not depend
on choosing between README, roadmap, milestone, campaign, memory, or
tool-specific instruction copies.
4. **Separate evidence from contracts.** Source comments explain the current
invariant. Dated research records preserve investigation history. Raw
captures live in an explicit artifact tier.
5. **No count-only gates.** A test total is meaningful only when the report says
which hermetic, installed-DAT, live, visual, manual, and diagnostic lanes
actually ran.
6. **Bound every external interaction.** Process waits, network operations,
test runs, and release steps require timeouts, cancellation, and diagnostic
artifacts on failure.
7. **Small reversible slices.** Each slice gets focused tests, a complete gate,
a reviewable commit, a rollback description, and a plan-ledger update.
8. **No opportunistic feature work.** New gameplay features wait unless needed
to prove or repair a release blocker.
## 3. Knowledge-preservation protocol
Every cleanup candidate is classified before it moves:
| Class | Durable value | Destination |
|---|---|---|
| Current invariant | Required behavior, ordering, ownership, threading, or retail rule | Short source comment and/or maintained architecture contract |
| Decision rationale | Alternatives considered, failed attempts, tradeoffs, gate outcome | Dated decision/research record linked from the current contract |
| Reproducible evidence | Minimal fixture, retail symbol/address, script, checksum, expected result | Versioned fixture/research record in Git |
| Raw evidence | Large logs, captures, Ghidra state, screenshots, dumps | Approved versioned artifact store with manifest, hash, provenance, and retention policy |
| Superseded or incorrect claim | Historically useful but no longer operative | Marked `SUPERSEDED` with successor link; archive after references are migrated |
| Duplication/noise | Repeats a preserved fact and adds no independent evidence | Delete only after destination/link validation |
Before deleting or rewriting historical material, all of these must be true:
- its current invariant is recorded at the owning code or architecture seam;
- useful retail provenance, failed approaches, and acceptance evidence remain
searchable under stable identifiers;
- inbound links and source comments point at the surviving destination;
- any raw artifact has an approved distribution, privacy, and licensing status;
- the replacement was reviewed by someone other than its author;
- the complete gate passes after the move.
Git history alone is not the preservation mechanism. History may later be
rewritten to remove large or legally restricted artifacts.
- Publicly distributed source and artifacts have an approved licence and
complete notices/provenance inventory.
- Users are told exactly how credentials are stored and removed.
- The launcher updater's production manifest and archives are generated and
verified by a repository-owned release process.
## 11. R5 — One current documentation authority
### Work
- Correct public README claims to the actual Vulkan-only client and retained UI.
- Make `docs/README.md` stable navigation plus a generated current-status block
sourced from one structured milestone/release ledger.
- Keep architecture documents limited to durable boundaries, ownership,
threading/lifetime, and data flow. Move commit/test-count/rollback chronology
to dated closeout records.
- Replace duplicated `AGENTS.md`/`CLAUDE.md` product truth with one maintained
tool-neutral source and generated thin adapters; fail CI on drift.
- Mark every memory/plan/spec as current, active, superseded, or historical.
- Normalize active issue/divergence indexes and validate their IDs, statuses,
paths, and links mechanically.
- Apply the knowledge-preservation protocol before shortening any campaign or
issue record.
### Exit criteria
- A new maintainer receives the same current answer from README, documentation
map, architecture, milestone/status ledger, and agent instructions.
- No current authority links missing private `claude-memory`, absent skills, or
developer-local paths.
- Historical records remain searchable but cannot override current truth.
## 12. R6 — Dead surfaces, diagnostics, and tools
### Work
- Decide and then remove or explicitly support the unreachable
`IPanelRenderer`/old panel stack and its tests.
- Remove stale OpenGL/framebuffer/ImGui apparatus and failed temporary-cleanup
markers from shipping assemblies after preserving useful evidence.
- Stop including the smoke plugin in release output by default.
- Classify every tool/script as supported, diagnostic, research-only, or
archived. Repair the five broken C# tools chosen as supported; remove
developer-home/old-worktree paths and document exact prerequisites.
- Centralize environment/diagnostic configuration at composition roots.
### Exit criteria
- Shipping assemblies and package contents contain no abandoned presentation
backend or sample plugin by accident.
- Every supported tool builds from the pinned clean checkout.
- Research-only tools are clearly invoked outside the release build.
## 13. R7 — Plugin and configuration contracts
### Work
- Enforce supported plugin API versions before loading code.
- Implement manifest dependencies or remove the unsupported promise.
- Publish/version `AcDream.Plugin.Abstractions` if plugins are advertised.
- Report registration cleanup and callback failures without preventing
best-effort teardown; prove collectible load-context release under failures.
- Replace absent/null allow-list ambiguity with one explicit production default.
- Replace direct hot-path environment reads/process-static mutable diagnostics
with immutable session-scoped configuration and typed sinks.
### Exit criteria
- An incompatible plugin fails before activation with an actionable message.
- Disable/dispose reports all cleanup failures and cannot silently retain host
registrations.
- Graphical and headless hosts have the same documented plugin/config default.
## 14. R8 — Bounded structural decomposition
This slice begins only after R1–R3. File size alone does not authorize a split.
### Priority candidates
- `RuntimeSetPositionState`
- `TransitionTypes`
- `RetailUiRuntime`
- `LiveEntityRuntime`
- `WorldSession`
- `WbDrawDispatcher`
### Rules
- Identify one ownership/lifetime or pure-algorithm seam at a time.
- Preserve a single state owner; do not replace a large class with mirrored
mutable state or a service graph of aliases.
- Prefer partial-file navigation when a state machine must remain one owner.
- Establish behavior/sabotage tests before extraction and remove corresponding
temporary source-text freezes afterward.
- Replace Chorizite/GL vocabulary in prepared-content DTOs with versioned
acdream-owned semantics at a separately reviewed boundary.
### Exit criteria
- Each extraction reduces change coupling or improves ownership clarity; line
count reduction alone is not success.
- Runtime behavior, retail evidence, allocations, and teardown ledgers remain
equivalent under the relevant focused and complete gates.
## 15. R9 — Repository artifact migration
### Work
- Inventory the approximately 575 MiB of Ghidra state, 299 MiB research tree,
and 151 MiB tracked logs by provenance, sensitivity, reproducibility, and
ongoing value.
- Keep compact fixtures, scripts, tool versions, summaries, and checksums in
Git. Move approved raw bundles to a versioned artifact store.
- Add a manifest/bootstrap command that verifies artifact identity.
- Define retention, redaction, access, and backup policy.
- Treat Git-history rewriting as a separately approved migration with backup,
contributor coordination, remote replacement, and verification. Never do it
as an incidental cleanup command.
### Exit criteria
- A normal clone contains what build/test/maintenance requires without opaque
generated databases or raw logs.
- Authorized researchers can retrieve exact approved evidence by manifest and
hash.
- Restricted or non-redistributable material is absent from public history.
## 16. R10 — Release-candidate gate
From a fresh clone on every supported release platform:
- restore with the pinned, locked toolchain;
- build every shipped product and supported tool with zero warnings;
- run the complete hermetic test lane with no failures, silent no-ops, hangs,
or generic retries;
- run and report the applicable DAT, connected, visual/listening, updater,
installer, graceful-shutdown, and rollback gates;
- generate versioned per-RID packages, plugin abstraction package if supported,
SBOM/provenance/checksums, and updater manifest;
- install/update/rollback using only public release instructions;
- verify package contents contain no credentials, developer paths, smoke
plugin, raw probes, or unapproved research artifacts;
- obtain technical and licensing/provenance approval.
Public release remains **NO-GO** until every blocking slice is closed.
## 17. Cross-session execution ledger
This table is the resume authority. Update it in the same commit as every slice
checkpoint. Do not infer status from chat history.
| Slice | Status | Commit(s) | Evidence/gates | Exact next action |
|---|---|---|---|---|
| R0 | PLAN ACCEPTED; R4 OWNER DECISION DEFERRED | — | Audit complete at `15539a22`; user completed R1–R3 | Retain the plan and audit artifacts as campaign authority |
| R1 | COMPLETE; MERGE AUTHORIZED 2026-08-18 | `0a934cf5` | 2026-08-18 checkpoint below; F-009/T-001 resolved and committed | Merge with the complete R1–R3 stabilization branch |
| R3 | COMPLETE; MERGE AUTHORIZED 2026-08-18 | `14d371a0`, `b64c8041` | Final inventory: 1,254 files, 11,414 attributed methods, 22 approved source readers; Release gate: 14,346/14,346 | Merge the stabilization branch; retain the closeout ledger as authority |
| R4 | DEFERRED BY USER FOR FRIEND-ONLY RELEASE | — | F-001/F-026/F-031/F-034 remain public-release blockers | Resume before any public release; keep credentials and research artifacts out of friend packages |
| R5 | DEFERRED BY USER | — | F-002/F-003/F-006/F-007/F-011/F-020/F-027/F-029/F-032 | Resume later with the bounded documentation-authority goal |
| R6 | NOT STARTED | — | F-004/F-005/F-012/F-016/F-023/F-028 | Wait for R3/R5 |
| R7 | NOT STARTED | — | F-017/F-018/F-024/F-025 | Wait for R3 |
| R8 | NOT STARTED | — | F-008/F-013/F-033 | Wait for R1–R3 and cleanup decisions |
| R9 | NOT STARTED | — | F-034 plus R4 provenance decisions | Wait for R4 |
| R10 | NOT STARTED | — | All blocking findings | Wait for blocking slices |
| **Membership / the "flap"** | `ACDREAM_PROBE_FLAP`: render cell = `0x8A020164` on **100%** of 188,732 frames across **526 distinct camera angles**, `res=None` always; `ACDREAM_PROBE_PVINPUT` flood is stable-per-angle (17/10/8…), never oscillates at a fixed view; 1 `[cell-transit]` (the spawn teleport) total |
| **Dat-geometry z-fight** | `Issue176177DungeonSeamInspectionTests.CorridorNeighborhood_CoplanarOverlappingDrawnPolyPairs` seeded on the ACTUAL cell `0x8A020164` + neighbors → **zero** coplanar drawn pairs at the z=−6 corridor floor (only the benign same-cell z=−12 under-hall floor tiling in `0x011E`) |
| **Dat-geometry z-fight** | `Issue176177DungeonSeamInspectionTests.Diagnostic_CorridorNeighborhood_CoplanarOverlappingDrawnPolyPairs` seeded on the ACTUAL cell `0x8A020164` + neighbors → **zero** coplanar drawn pairs at the z=−6 corridor floor (only the benign same-cell z=−12 under-hall floor tiling in `0x011E`) |
| (iii) S2 toggle | n/a | n/a | n/a | dead code, no call sites |
| (iv) both | 19 (identical) | yes (identical) | yes (identical) | follows from (ii)+(iii) |
## 4. The actual mechanism (found by hand-tracing the live capture against `PlayerMovementController.cs`, independently confirming it explains BOTH mined freeze events exactly)
Neither S1 nor S2 touch velocity. The full-zero-in-one-tick signature
(§1.2) is produced by two pre-existing, Campaign-P-independent pieces
working in sequence:
1. **The landing tick** (`PlayerMovementController.cs`, the
| get_max_speed (0x00527CB0) | ported-with-cite | `MotionInterpreter.cs:2632` (`GetMaxSpeed`; doc comment includes a forensic re-derivation of the ×4.0 constant from raw x87 disassembly, UN-2 resolved) |
| **get_adjusted_max_speed (0x00527D00)** | **UNPORTED-UNEXPLAINED** | None found — independently confirmed (see intro above). Sibling of `get_max_speed`; retail `InterpolationManager::adjust_offset` (pc:353107, `0x00555dbe`) chooses between the two via a static toggle `fUseAdjustedSpeed_`. acdream's dead-reckoning catch-up path (`RemoteMotionCombiner.cs:90`, `RuntimeRemotePhysicsUpdater.cs:254,291,685`) only ever calls `GetMaxSpeed()`. No register/ISSUES row covers this. |
| Create (0x00528C00) | ported, uncited | `MotionInterpreter.cs:795` — retail's `Create` calls `SetWeenieObject`/`SetPhysicsObject` while `initted==0`, making both no-ops; plain field assignment is behaviorally identical |
| Destroy / ~CMotionInterp (0x00527B40 / 0x00527FF0) | trivial, skipped | GC-obviated (manual `pending_motions` free-list walk superseded by `LinkedList<T>`) |
| HandleEnterWorld (0x00694750) | SYMBOL-ARTIFACT | see methodology note |
| InqStyle (0x00527B10) | SYMBOL-ARTIFACT | see methodology note |
**~85% ported-with-cite** (35/41; ~95% of the 37 real/substantive methods
after excluding 2 symbol-artifacts and 2 GC-obviated dtors). One genuine gap.
### CSequence (28 symbols.json entries)
| Retail method (addr) | acdream status | Cite |
|---|---|---|
| ctor (0x005249F0) | ported, trivial | `CSequence.cs:81` (zero-init, matched by C# field defaults) |
| set_object (0x00524820) | SYMBOL-ARTIFACT, functionally ported | address really resolves to `DBObj::SetDID`; behavioral equivalent is the public `HookObj` field, `CSequence.cs:300` |
| HandleEnterWorld / RemoveLinkAnimations (both 0x0051BDD0) | ported-with-cite / SYMBOL-ARTIFACT-duplicate | `MotionTableManager.cs:373` — pseudo-C shows only one body at this address |
| SetPhysicsObject (0x0051BBC0) | deliberately-absent-with-reason | `MotionTableManager.cs:20-22` (file header: "C# has no physics_obj field — R2 leaves the CPhysicsObj::MotionDone target as an injectable seam") |
| GetMotionTableID (0x0051BC10) | unported, verified low-risk | retail's only caller (`CPartArray::SetMotionTableID @ 0x005186e0`) uses it purely as a dirty-check before destroying+reconstructing the whole manager; acdream gets the same capability by constructing a fresh `AnimationSequencer`/`MotionTableManager` (`AnimationSequencer.cs:287`) |
| SetMotionTableID (0x0051BBD0) | unported, verified low-risk | its only caller in the entire retail client is its own `Create` factory (single call site, pc:290526) |
| remove_redundant_links (0x0051BF20) | ported-with-cite | `MotionTableManager.cs:191` (independently read in full — byte-for-byte match including the `0xb0000000`/`0x70000000` block masks) |
| get_link (0x00522710) | ported-with-cite | `CMotionTable.cs:201` (independently read — the reversed-key branch, field-validated per its own doc comment) |
| GetObjectSequence (0x00522860) | ported-with-cite | `CMotionTable.cs:255` — independently read in full; the single highest-stakes function in this whole sweep (branch-heavy style/cycle/action/modifier dispatcher), ported branch-for-branch with inline citations, including three explicitly-preserved retail quirks (A4-#1 double-hop tick counting never double-charging the base cycle; A4-#2 silent no-op in `ChangeCycleSpeed` when old speed ~0 but new speed isn't; A4-#5`ReModify`'s lockstep-snapshot termination bound) |
| W (run) | `SendMovementEvent` fires on any command-list head edge; wire carries `WalkForward`, `HoldKey.Run`, raw `forward_speed` (pre-scale). ACE auto-upgrades to `RunForward` for observers. | `PlayerMovementController` line 2106-2225: `outForwardCmd=WalkForward`, `outForwardSpeed=1.0f` (raw), `IsRunning=input.Run`; `changed` fires on cmd/hold/speed edges. `RawMotionStatePacker` D1 default-diff omits unchanged fields. | **Parity** (D6.2b/D1 shipped, verified 2026-07-01) |
| W+Shift (walk) | Same tree, `HoldKey.None`, `forward_speed=1.0` (not run-scaled — ACE/observer scaling is a display-time concern, not sender concern). | Same — `axisHoldKey = movement.IsRunning ? Run : None` in `LocalPlayerOutboundController.BuildRawMotionState` (:234-244). | **Parity** |
| Backward (X) | `WalkBackward` tag, own independent forward-channel entry; `adjust_motion` applies a flat **-0.65×** speed multiplier for the walk-forward↔backward pair (`apply_run_to_command`/`adjust_motion` 0x00528010, `acclient_2013_pseudo_c.txt:305343-305400` — **FACT**, spot-read confirms the `0x45000006→WalkForward, speed*=-0.65` canonicalization). | `outForwardCmd=WalkBackward`, `outForwardSpeed=1.0f` (PlayerMovementController :2111-2115). The **-0.65× backward scale lives in `MotionInterpreter.cs:543-546`** ("Retail-exact value; do not round to 0.65f") and is applied on the *interpreted* (local-animation) side, not re-derived on the wire (wire stays raw 1.0, matching D6.2b's "ACE recomputes" model). | **Parity** — same separation-of-concerns retail uses (raw wire, scaled interpretation) |
| Strafe (Z/C) | `SideStepRight`/`SideStepLeft`; `adjust_motion` applies a flat **×1.248** (`(3.12/1.25)*0.5`) animation-rate scale, THEN `apply_run_to_command`'s SideStepRight branch (if Run) multiplies by `runRate` and clamps magnitude to **3.0** (`0x00527be0:305102-305122` — **FACT** for the 3.0 constant and the runRate scale; **INFERENCE** on the exact snap-vs-clamp branch polarity, x87 flag test unresolved by BN). | `MotionInterpreter.cs:558-564` cites the retail `±3.0` clamp and the 1.248 sidestep scale explicitly; `_activeInputSidestepCommand`/`SidestepUsesRunHold` in `PlayerMovementController.cs:2129-2133` carry the channel through to the wire. | **Parity** (ported; the one open item is the same x87-ambiguous branch retail's own disassembly leaves fuzzy — not an acdream gap) |
| Turn (A/D) keyboard | `adjust_motion` canonicalizes Left→Right (`speed *= -1`), then `apply_run_to_command`'s TurnRight branch multiplies by a flat **1.5×** when hold key is Run (`0x00527be0:305096-305100` — **FACT**, byte-confirmed this session). Turn is a channel fully independent of forward/sidestep. | `MotionInterpreter.cs:554``RunTurnFactor = 1.5f`, applied inside the ported `apply_run_to_command` (:1355+). Turn channel (`_activeInputTurnCommand`/`_activeInputTurnSpeed`) is independent of forward/sidestep in `PlayerMovementController.cs:2139-2143`. | **Parity** |
| Autorun (Q) | See §4 below — separate section, real divergence found. | | **Divergent** |
| Mouse-look turn (MMB) | `CameraSet::ToggleMouseLook`/`Rotate` (0x00457490/0x00458310) drive ordinary `TurnLeft`/`TurnRight``MovePlayer` calls, always `HoldKey.Run`; speed = 2×filtered horizontal delta, dead-zone 0.02, cap 1.5. `MoveToState` sent on start/stop and every 0.5 s while active. | `MouseTurnDeadZone=0.02f`, `MouseTurnSpeedScale=2.0f`, `MouseTurnMaximumSpeed=1.5f`, `MouseMovementEventInterval=0.5f` (`PlayerMovementController.cs:376-380`) — exact match. | **Parity** (previously verified 2026-07-15, re-confirmed this session) |
| Mouse-move-to (click-to-move) | Not part of the CommandInterpreter WASD tree; routes through `MoveToManager`/`MoveToPosition` (§3). | Same split in acdream (`MoveToManager.cs`, separate from `PlayerMovementController`'s per-frame channel). | **Parity** (architectural match) |
| Stop (S key / all keys released) | `CommandInterpreter::UseTime` gates `ShouldSendPositionEvent` first, then falls through; a full command-list-empty state issues `MovePlayer(Ready, ...)` idle re-sync via `ApplyCurrentMovement`. | `PlayerMovementController` idle path falls to `_motion.RawState.ForwardCommand` staying at `Ready` default (0x41000003), consistent with retail's ctor default. | **Parity** |
**Consequence:** for any remote entity that is standing, walking, turning,
sidestepping, or backing up (i.e. every state except actively running
forward), acdream's catch-up cap is **exactly 4× retail's** (both use the
×2.0 `MaxInterpolatedVelocityMod`, but acdream also always applies the ×4.0
`RunAnimSpeed` that retail reserves for the RunForward-only branch). For a
run-rate-2.94 character standing still: retail caps catch-up at
2×2.94 ≈ **5.9 m/s**; acdream currently caps it at 2×2.94×4 ≈ **23.5 m/s**
— a 4× overshoot. Only while the remote is genuinely in a `RunForward`
cycle does acdream's flat ×4 approach retail's own (still not identical,
since retail additionally normalizes by `current_speed_factor`, an
unported field).
This existed underneath a prior investigation (UN-2, resolved 2026-06-12,
cited directly in `MotionInterpreter.cs:2601-2630`) that correctly
byte-verified the ×4.0 constant *inside*`get_max_speed`, but did not catch
that `get_max_speed` itself is the **dead default branch** — retail's real
call site always takes `get_adjusted_max_speed`, which only applies that
×4 conditionally. This is a strong root-cause candidate for the "remote
catch-up feels too fast/twitchy for non-running remotes" symptom family
(#41 blips, #165 wall-penetration-before-stop) that the same doc-comment
explicitly says to look elsewhere for — this audit's finding redirects that
search back to this exact seam.
**Recommendation (report-only — no fix applied):** port
`CMotionInterp::get_adjusted_max_speed` as a new `MotionInterpreter` method
(needs `current_speed_factor`, currently absent — a new tracked field,
citing `0x00527d00`/line 305145), and switch every catch-up-cap call site
above from `GetMaxSpeed()` to the new adjusted accessor, gated by the
(retail-fixed-true) `fUseAdjustedSpeed_` semantics — i.e. always call the
adjusted variant, since retail's own flag is always on. This is the
single highest-value fix candidate in this audit.
### 2b. Snap / teleport thresholds — two distinct constants, both present
**FACT.** Retail has two separate thresholds, and acdream has ported both
correctly:
| Constant | Retail value | Retail site | acdream value | acdream site |
|---|---|---|---|---|
| Hard routing snap (give up on interpolation entirely, `MoveOrTeleport`) | **96.0 m** | `CPhysicsObj::MoveOrTeleport` 0x00516330, line 284342-284361 | Not separately named in `InterpolationManager.cs` — this gate lives upstream, at the physics dispatch layer (`RuntimeRemotePhysicsUpdater`/teleport handling), not audited line-by-line this session; flagged as **needs a follow-up grep to confirm the 96 m constant is present at the equivalent acdream call site** (not found during this pass — see gap catalog). | — |
| Enqueue-time "far jump, pre-arm blip" (`AutonomyBlipDistance`) | **100 m outdoor / 20 m indoor** per prior cdb live-attach (project's own 2026-05-0x capture) — the *decomp* constant itself (`GetAutonomyBlipDistance`, 0x0050eb70) is BN-garbled and not independently re-derivable from static text alone this session (**INFERENCE**, cdb-sourced not decomp-sourced) | `CPhysicsObj::GetAutonomyBlipDistance` 0x0050eb70 | `AutonomyBlipDistance = 100.0f` (`InterpolationManager.cs:99`), comment explicitly notes "indoor is 20 m" as a known-but-unported distinction | `InterpolationManager.cs:99` |
**Verdict: Parity** for the enqueue-time 100 m outdoor constant (matches
the project's own prior cdb finding); the indoor-20m variant is
**Divergent/incomplete** — acdream uses a flat 100 m regardless of
indoor/outdoor, an existing known gap already flagged in the code's own
comment (not a new finding, confirmed still present).
### 2c. Position-history queue depth — Parity
**FACT.** Retail: 20 entries (`0x14`), head-evicted on overflow, confirmed
directly at `InterpolateTo` line 353004-353021. acdream:
| `WalkRunThreshhold` | **15.0 m** | 15f (`:179`) | same | Parity — and acdream's own comment explicitly flags the ACE-divergence trap (ACE uses 1.0) and refuses to copy it |
| `CanCharge` (bitfield 0x10) | **clear (false)** | `false` (`:102`) | same | Parity — same explicit ACE-divergence-trap comment (ACE sets it true by default) |
the `Forward` boolean true; `Run: !walking` (:71) is evaluated **live,
every poll**, from whatever `InputAction.MovementWalkMode` (Shift) is
currently held — **independent of autorun state**.
- **Cancel set:**`HandlePressedAction` (:80-96) cancels autorun on Press
of `{MovementBackup, MovementStop, MovementStrafeLeft, MovementStrafeRight}`
only.
### Verdicts
| Behavior | Retail | acdream | Verdict |
|---|---|---|---|
| Default key | Q | Q | Parity |
| Pace while autorunning | **Always Run** (hard-forced `HoldKey.Run`, independent of walk-mode toggle) | **Follows the live `MovementWalkMode` toggle** — if the user has Shift/walk-mode held (or toggled) while or after engaging autorun, autorun walks instead of runs | **Divergent.** Confirmed by direct read of `DispatcherMovementInputSource.cs:71` (`Run: !walking`, unconditioned on `AutoRunActive`) against retail's `ApplyCurrentMovement` autorun branch (`SetHoldKey=1, HoldKeyToApply=1` hard-coded, `0x006b3486`). |
| Cancel on Stop key | Not separately identified in retail's cancel set this session (no explicit S/Stop-key cancel site found; likely folds through `auto_run`/`transient_state` reset elsewhere) | Yes, explicit (`MovementStop`) | Likely parity, low-confidence on the retail side |
| **Cancel on fresh Forward press** | **Yes** — `HandleNewForwardMovement` explicitly cancels autorun on every new W press (`0x006b3d60`) | **No** — `MovementForward` is absent from `HandlePressedAction`'s cancel list (:86-91); architecturally reachable (the same Press edge already drives `CombatAttackInputFrameAdapter.HandleMovementInput`'s abort-for-movement check, `GameplayInputFrameController.cs:24-39`) but not wired to `CancelAutoRun()` | **Divergent, confirmed gap.** In acdream, pressing W while autorunning is currently a no-op (autorun stays latched, `Forward` was already true); in retail, the same press explicitly drops autorun and hands control back to the held key. |
| Mouse-look interaction with autorun | No evidence found of a direct interaction; mouse-look drives its own `TurnLeft`/`TurnRight` channel independent of `auto_run` | Same — mouse-look turn channel (`_activeInputTurnCommand`) is independent of `AutoRunActive` | Parity (no interaction expected on either side) |
| Both-mouse-buttons-run | No evidence found of a distinct "both mouse buttons = run forward" binding in the decompiled `CommandInterpreter`/`CInputManager` text searched this session | Not implemented (no such binding in `KeyBindings.RetailDefaults()`) | **Ruled out as a feature** — this session found no retail mechanism for it, so acdream's absence is not a gap. (If the user recalls this from live retail play, it would warrant a targeted cdb trace on `IInputActionCallback`/mouse-button handlers; not found in the static decomp searched here.) |
---
## 5. Turn-rate composition
**FACT**, direct decomp read this session, `CMotionInterp::apply_raw_movement`
(`0x005287e0`, line 305817-305834) → three independent
`cn=UnitZ` default on a blocked move **is** retail-faithful
(`validate_transition` does the identical default). The real divergence
is **upstream** — at tick-22760, acdream's `collision_normal_valid` was
`false` where retail's was `true` (retail had recorded the door-face
normal `(0,+1,0)`). The candidate site named in the P2 doc §5 was "the
`PathClipped`/`collide_with_pt` arm... or a sibling Path-1-class function
not yet read."
### 2.2 PathClipped is NOT the answer (checked this pass, negative result — FACT)
`ObjectInfoState.PathClipped` (`TransitionTypes.cs:32`, bit `0x8`) is
only set on a mover when `MoverPhysicsState & PhysicsStateFlags.Missile
!= 0` (`PhysicsEngine.cs:1160-1163`), with an explicit citation to
retail's own `CPhysicsObj::get_object_info` (`0x00511CC0`): "Missile
contributes PathClipped only." A normal player push against a door is
not a missile mover, so **neither acdream nor retail would set
PathClipped for this scenario** — this rules out "PathClipped state
differs between engines" as shape-1's cause. (The camera/viewer sweep
does carry PathClipped via a different, explicit caller-supplied flag,
but that's a different mover than the one in the tick-22760 door-push
capture.)
### 2.3 The real candidate: acdream's Path-6 sphere1(head)-hit handling diverges from retail/ACE (FACT for the divergence, INFERENCE that it explains tick-22760)
Retail's `BSPTREE::find_collisions`, in the **not-yet-in-Contact**
branch (`state&1==0`, i.e. airborne / first contact — pseudo-C:323784-
323836, `0x0053a4e3`-`0x0053a730`+): when sphere0 (foot) does **not**
hit but `num_sphere > 1` and sphere1 (head) **does** hit, retail does
**not** defer through `SetCollide`/`Adjusted` — it calls
`COLLISIONINFO::set_collision_normal`**directly** with the head poly's
transformed normal and returns `COLLIDED_TS` (`2`) immediately
to log, at the tick-22760 resolve, which of `hit0`/`hitPoly0`/`hit1`/
`hitPoly1` were non-null/true inside `BSPQuery.cs`'s Path-6 dispatch
(a one-line `Console.WriteLine` gated behind the existing
`ProbeIndoorBspEnabled`/`ProbeBuildingEnabled` diagnostics, or a new
narrowly-scoped probe flag per the project's diagnostic-owner pattern).
**Accept criterion:** if `hit0`/`hitPoly0` are both null/false **and**
`hit1`/`hitPoly1` fire, §2.3's hypothesis is confirmed — the fix is to
port retail's direct sphere1-hit-without-sphere0-hit → `Collided` +
`SetCollisionNormal` branch into `BSPQuery.cs`'s Path 6 (mirroring the
already-correct Path 5/Contact-branch treatment at
`BSPQuery.cs:2103-2140`, which already handles the analogous grounded
case correctly — this would be a narrow, well-precedented port, not a
new design).
**Reject criterion:** if sphere0 hits too (`hit0` or `hitPoly0` truthy),
this hypothesis is wrong for tick-22760 specifically, and the search
should move to the *other* named-retail sibling not yet read this pass —
`BSPTREE::collide_with_pt`'s own internal structure for a **non-PathClipped**
context is not reachable (its outer gate requires `state&8`), so the
next candidate would be whatever governs `CObjCell::find_obj_collisions`'s
insertion order relative to `find_env_collisions` for a door's *building*
channel (the BR-7/A6.P4 per-cell shadow architecture) — not yet examined
this pass; would need a fresh read of that dispatch specifically for
polygon ordering/precedence when multiple candidate polys are tested per
cell.
---
## 3. #116 shape-2 — first-airborne-frame hard-stop vs in-frame slide (strong structural finding, INFERENCE, narrows but does not eliminate the need for a confirming run)
### 3.1 The dispatch structure resolves the ROUTING question without cdb (FACT, cross-referenced against 3 sources: raw BN pseudo-C, ACE, current acdream)
Both the raw retail decomp and ACE's `BSPTree.cs` (an independent,
clean-language port — the "fastest oracle" the mission suggested)
show the **same two-tier gate**, keyed on `ObjectInfoState.Contact`:
`Assert.Equal(residentLandblocks, prepared.Engine.LandblockCount)`. They assert the exact
mechanism being removed.
Replace with `CollisionPreparationCostIsIndependentOfResidentWorldSize` — a strictly
stronger invariant, because it pins the *property* (bounded, world-size-independent work)
rather than a mechanism:
```
Run the full admission → preparation → seal → commit sequence twice,
at residentLandblocks = 32 and residentLandblocks = 256.
Assert total preparation advances(32) == total preparation advances(256) // O(changed)
Assert total seal WorkUnits(32) == total seal WorkUnits(256) // closes the seal scans
Assert every step.WorkUnits <= K // K = retained per-step bound
Assert admissionAllocation in [1, 128 KiB] // kept from :973
Assert prepared.Engine.LandblockCount == 0 after preparation completes // stronger than :974:
// the draft now holds ONLY the target
```
Add two more:
- `CommitAppliesOneLandblockDeltaInASingleCall` — the engine-mutating
`CommitCollisionGeneration` call drains the apply cursor to `Completed` before it
returns; the active world holds no target-prefix content before it and the complete
target after it, with no observable intermediate.
- `CommitTimeRefloodMatchesPrecomputedReflood` — for a fixed scenario, the owner set and
each owner's resulting cross-cell set after a commit-time reflood are **equal** to what
the pre-change staged reflood produced. This is the proof that D2 is a scheduling
change and not a semantics change, and it is the test that makes the perf framing in
(d) legitimate.
**Keep unchanged:** every `Assert.InRange(seal.WorkUnits, 0, 1)` at `:558, :667, :747,
:848, :1797, :2020, :2318, :2428, :3033` — the seal stays metered; the zero-managed-byte
commit assertions (the delta lists are built during seal, so the apply must still be
allocation-free); and `CommittedPreparationRevokesItsStagingCollisionRoot` (`:1664`) in
spirit — the staging root must still be revoked after commit, it simply no longer becomes
the active root.
### Migration plan
| Slice | Change | Gate |
|---|---|---|
| **O1** | Per-prefix installed-key ledger in `CollisionWorldState`, maintained by the install/remove paths. Rewrite the seal's ten full-map scans (`PhysicsDataCache.cs:1081-1160`, `CellGraph.cs:184, 203`) to enumerate that set. Removes the cross-frame active-map enumerators. **Behaviour-identical; a pure win that lands alone.** | existing suites green + the new seal-independence assertion |
| **O2** | `PhysicsEngine.CommitLandblockReplacement` drains `LandblockReplacementApplyCursor` against the active root instead of `TransferTo`. Extend the `_suppressCollisionOwnerJournal` bracket over the whole apply. Retirement cursor destination → active root. | focused Runtime physics suite + connected lifecycle gate |
| **O3** | Empty staging root: delete `CollisionStagingBuilder` phases 0–8. Move retained-owner reflood into the commit call (retail `init_objects` → `recalc_cross_cells`). Delete the now-dead peer-rebase/journal paths and their tests. | full ladder below |
> 0x0052b420 → `CPhysicsObj::recalc_cross_cells` 0x00515a30). Equivalence is
> pinned by `CommitTimeRefloodMatchesPrecomputedReflood`; world-size
> independence by `CollisionPreparationCostIsIndependentOfResidentWorldSize`.
> Residual timing/order compression vs retail: AD-62.
### 1b. New row AD-62 (residual timing/order compression), adaptation class
| AD-62 | **Adaptation.** Commit-time collision reflood granularity/order: retail runs `CObjCell::init_objects` per CELL at cell hydration and `CPhysicsObj::recalc_cross_cells` per object as each cell loads; acdream runs the equivalent once per LANDBLOCK replacement inside the single synchronous activation call, walking the sealed owner list then the live prefix-owner slots (per-landblock granularity matches the streaming unit, same compression `ShadowObjectRegistry.RefloodLandblock` has always carried). An owner becoming target-associated mid-publication refloods at activation (the prefix-slot sweep) rather than at its own cell's hydration instant; a stationary owner adjacent to the target whose flood would only change through building/EnvCell bridges can carry frame-stale cross-cells between the seal capture and the activation sweep (movers self-heal per `SetPositionInternal`). | `src/AcDream.Core/Physics/PhysicsEngine.cs` (`CommitLandblockReplacement`); `src/AcDream.Core/Physics/ShadowObjectRegistry.cs` (`ApplyCommittedOwnerReplacement`, `RefloodPrefixOwnersAfterReplacement`) | Late/stale cross-cell rows for a non-moving seam object for a few frames around a landblock publication — an object collidable through a wall seam or briefly not collidable where new topology landed | Low | `CObjCell::init_objects` 0x0052b420; `CPhysicsObj::recalc_cross_cells` 0x00515a30; `CPhysicsObj::SetPositionInternal` tail 0x00515330 |