Commit graph

133 commits

Author SHA1 Message Date
Erik
0c552eecac fix(world): honour SetOmega — the birds and butterflies fly again
Ambient flyers played their wing animation and stayed put.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 18:09:02 +02:00
Erik
48c44abd4a fix(IconForge): point the texture path at the real dump directory
render.py was lifted from the scratch pipeline with its default TEXDIR still
aimed at tools/IconExtract's build output — a tool that is not in the repo.
forge.py overrides the value, so the icons built correctly and the staleness
was invisible; anyone importing render.py directly would have been sent to a
path that never existed.

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

Both icons still reproduce byte-for-byte.

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 14:42:10 +02:00
Erik
c155db74d1 test: introduce Lane=Timing for load-sensitive tests, and stop chasing them individually
All checks were successful
CI / linux-portable (push) Successful in 3m27s
CI / windows-gate (push) Successful in 5m34s
CI / release (push) Successful in 1m54s
Four separate fixes each surfaced a different member of the same family, and
one of them (serializing Core.Net.Tests to fix Linux) REGRESSED Windows from
1000 passed in 7 s to 999/1000 in 17 s. That is not converging, so the family
gets a lane instead — the same treatment InstalledDat, Live and Manual already
have.

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

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

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

Also removes the ad-hoc Core.Net parallelism special-case from the Linux job,
which this supersedes.
2026-08-19 15:29:10 +02:00
Erik
ff01423f3f ci: put the launcher's update pointer in a release, and delete the dist branch
Some checks failed
CI / linux-portable (push) Failing after 3m22s
CI / windows-gate (push) Successful in 5m27s
CI / release (push) Has been skipped
The dist branch existed to carry ~120 MB payloads that could not go on main.
Once payloads became release attachments it held one 500-byte manifest.json,
so it was a whole branch for a reason that no longer applied.

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

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

Versioned releases are retained, so older builds stay downloadable.
tools/publish-dist.ps1 is removed — publishing is CI's job now.
2026-08-19 14:40:41 +02:00
Erik
b746d3d61b ci: Gitea pipeline — gate on both self-hosted runners, publish alpha releases
Some checks failed
CI / linux-portable (push) Failing after 1s
CI / windows-gate (push) Failing after 6s
CI / release (push) Has been skipped
Every push to main now runs the gate on the self-hosted runners and, when
green, publishes a Gitea Release carrying the client, launcher+bake, and
manifest.

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

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

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 10:35:36 +02:00
Erik
f260260caf fix(launcher-feed): strip debug symbols from distribution payloads (103 MB -> 77 MB)
A stock publish shipped native debug symbols to players: libSkiaSharp.pdb
(80 MB) and libHarfBuzzSharp.pdb (20 MB) from Avalonia's rendering packages
were 100 MB of a 278 MB launcher payload. MSBuild's DebugType switches only
govern our own managed symbols, not native .pdb files arriving as package
runtime assets, so the payload build drops every .pdb before zipping.

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 19:49:06 +02:00
Erik
c5f0fbaaa4 test: audit helper-mediated source reads 2026-08-18 14:18:00 +02:00
Erik
ea17bc8624 test: remove vacuous diagnostic assertions 2026-08-18 12:48:07 +02:00
Erik
5fa9933636 test: remove exact duplicate coverage 2026-08-18 12:36:41 +02:00
Erik
056af276d0 test: classify remaining explicit waits 2026-08-18 12:16:53 +02:00
Erik
6faeb4a103 test: make prerequisite lanes fail honestly 2026-08-18 12:09:41 +02:00
Erik
c8c764a40e test: remove ambient timing from double-click contracts 2026-08-18 11:38:31 +02:00
Erik
3684e7b5e7 test: classify prerequisite lanes and own Avalonia sessions 2026-08-18 11:30:47 +02:00
Erik
c1a905004a test: separate diagnostic apparatus from release gates 2026-08-18 11:06:08 +02:00
Erik
8f490240d4 test: separate non-hermetic release lanes 2026-08-18 10:49:22 +02:00
Erik
c38f6b8852 build: make release restore reproducible 2026-08-18 10:29:00 +02:00
Erik
2ac054864d ci: add bounded complete release gate 2026-08-18 09:09:38 +02:00
Erik
9f9c116792 fix(launcher): harden LA11 gate evidence 2026-08-15 01:45:15 +02:00
Erik
accd01a008 fix(launcher): harden Campaign LA11 gate evidence 2026-08-15 01:08:41 +02:00
Erik
134edabed2 feat(launcher): complete Campaign LA11 pre-gate support 2026-08-15 00:02:04 +02:00
Erik
f881e5b467 feat(launcher): prepare Campaign LA11 user gate 2026-08-14 23:42:30 +02:00
Erik
c1f1582576 fix(chat): Campaign CH user-gate round 2 -- portal notice rerouted to SpewBox, verbatim /help extraction, jump-in-air evidence
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>
2026-08-10 08:40:24 +02:00
Erik
e0e7888308 fix(chat): CH2 rework — SpewBox tick-driven visibility + binary-derived error table
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>
2026-08-09 18:14:26 +02:00
Erik
535f41bbdf docs(physics): #347 premise revision — retail may alternate too; ftp:edge ratio is the discriminator
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run
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>
2026-08-07 13:42:14 +02:00
Erik
7542cfd3c2 docs(physics): #345 D0 — implementer's correct STOP + ACE cross-check addendum + round-2 stack-capture script
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>
2026-08-07 12:51:57 +02:00
Erik
3dd41c66e1 docs: #345 live retail trace — the glide is cliff_slide firing every tick; our insert loop never routes there
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>
2026-08-07 11:22:27 +02:00
Erik
c24bc571cf fix(physics): enforce retail step-down support radius (#273) 2026-07-31 12:10:03 +02:00
Erik
5a0f9868a6 fix(physics): port retail slope landing stop 2026-07-31 09:10:53 +02:00
Erik
909bff0aa5 test(physics): #265 mining tool + real-trajectory replay harness for the steep-slope response family
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>
2026-07-30 17:55:49 +02:00
Erik
e6a87679b7 fix(render): read TransparentPartHook opacity by the real part ordinal, not 0
Some checks failed
Headless portability / portable-headless (ubuntu-latest) (push) Has been cancelled
Headless portability / portable-headless (windows-latest) (push) Has been cancelled
Headless portability / linux-graphical (push) Has been cancelled
Headless portability / linux-vulkan (push) Has been cancelled
The user reported crystal shards hovering in the air above every Bind
Stone on Coldeve (setup 0x020010AC) that do not exist in the retail
client. The DAT truth, extracted with the new tools/SetupInspect probe:
the model authors SEVEN parts - pedestal, spinning column, inner
crystal, and four shard meshes parked in a static ring at Z=3.0 in the
placement frame and every frame of the idle cycle - and frame 0 of that
idle cycle fires four TransparentPartHooks (parts 3-6, start=end=1.0)
each loop. Retail hides the shards through those hooks; the model
simply ships with permanently-hooked-invisible parts.

acdream's hook chain was intact end to end - the static-animating
workset captures the hooks (RetailStaticAnimatingObjectScheduler ->
AnimationHookFrameQueue -> TranslucencyHookSink), and
TranslucencyFadeManager committed translucency 1.0 for parts 3-6 -
but BOTH dispatchers' bare-GfxObj branch read the fade with a
hard-coded part index 0 under a false #188-era assumption ("a bare
GfxObj entity has exactly one part"). Every live server object is a
FLATTENED multi-part entity in exactly that branch: SetupMesh.Flatten
emits one bare-GfxObj MeshRef per Setup.Parts[i], order preserved,
AnimPartChanges replacing in place - so the MeshRef ordinal IS the
retail CPartArray ordinal TransparentPartHook.PartIndex addresses.
The committed invisibility for parts 3-6 was never consulted and the
shards drew forever. Proof the ordinal was trustworthy all along:
click-selection in the same loops already publishes it as the part
identity (Slice 4 picking runs on it in production).

Fix: both the legacy classifier and the packed oracle now pass the
per-part ordinal (partIdx / packedPart.PartIndex) to the translucency
lookup. Single-part objects still read index 0; the #188 door fades
are unchanged; the Setup-expanded branch already indexed correctly.
Any other object hiding authored parts via idle-loop hooks gets its
retail appearance from the same change.

tools/SetupInspect is the new reusable DAT probe that cracked this:
dumps a Setup's parts, parent indices, GfxObj vertex bounds, placement
frames, motion-table default cycle, sampled animation frames, and all
animation hooks.

Closes task #32's code side; the connected visual gate (shards gone at
the Bind Stone, base crystals and spin retained) is the acceptance.
App Release suite 3,968 / 3 skips.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 22:26:45 +02:00
Erik
3899ebe0fd docs(net): N5 accepted - Opus review PASS; loss gate strengthened per review
The review verified all three FAIL conditions absent (zero DROP_PCT=0
cost proven from code AND the decorator-absent baseline logs; the gate
fails explicitly on zero drops and zero recovery; teardown ordering
intact and ACE-safe) and reconciled the loss-ledger arithmetic packet by
packet. This acceptance folds in its two MEDIUM strengthenings: the
recovery assertion is now a per-direction conjunction (a one-direction
regression can no longer hide behind the other counter) and the three
keystream-health invariants (cksum-fail, sanity-drop, uncached-nak) are
asserted zero, turning the gate from "something recovered" into "loss
happened, both directions recovered, and the cipher ledger converged".
The unrecoverable-tail caveat now names the EnterWorldBody single-shot
alongside logoff/Disconnect and records ACE's gapped 1/s NAK trigger as
the mechanism. Script parse-validated; N6's gate run exercises it live.
N5 SHA 4e290f00 and its revert line recorded in the ledger.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 16:40:55 +02:00
Erik
4e290f00d8 feat(net): N5 - loss observability, lossy decorator, the connected loss gate
Campaign N Slice N5 (docs/plans/2026-07-29-network-transport-campaign.md
section 8 rung 3): the permanent removal of the loopback blindness that let
#260 ship. Local ACE never drops a datagram, so every historical connected
gate was structurally incapable of exercising the N1-N4 recovery machinery;
from this slice on, tools/run-connected-loss-gate.ps1 runs the standard
lifecycle route through deterministic seeded loss and passes only on proven
non-zero recovery.

Observability:
- [net-tick] gains resend/s nak-out/s nak-in/s rej-in/s dup-drop/s parked/s
  reclaim/s cache= nakset= - TransportStats window deltas mirroring the
  acks/s cumulative-delta pattern, plus the two instantaneous depths (the
  unbounded-like-retail sent-packet cache watchdog and the inbound NAK set).
  TransportStats gains RejectsReceived (inbound RejectRetransmit packets).
  Counters increment unconditionally; every string is behind
  NetDiagnostics.ProbeNet (Code Structure Rule 5).
- WorldSession.Dispose emits one cumulative [net-final] totals line so the
  loss gate asserts exact counters instead of reconstructing them from
  rounded per-second rates.
- LinkStatusSnapshot.PacketLossPercentage is deliberately NOT wired: filed
  #261 - retail's CLinkStatusAverages formula
  (LinkStatusHolder::GetPacketLossPercentage @ 0x00411370) must be located
  first; inventing a ratio is forbidden.

N4-review F3 fold-in:
- Fresh reliable sends stamp Header.Iteration = the session iteration
  through the same shared retail header build already cited for Time (N3)
  and the N4 control packets: FlowQueue::TransmitNewPackets @ 0x00547A60,
  the stack build at 0x00547A84/0x00547AA8. The control-header rule now
  holds across all three send shapes (fresh reliable, ack, NAK). ACE reads
  neither Time nor Iteration inbound (campaign section 3) - wire-safe, and
  resends keep the stamp verbatim per the N1 rebuild rule.

Loss injection (Transport/LossyTransportDecorator):
- IWorldSessionTransport wrapper with deterministic seeded per-direction
  loss. Config via NetDiagnostics typed env properties read once:
  ACDREAM_NET_DROP_PCT (0 = off = default), ACDREAM_NET_DROP_SEED (default
  1), ACDREAM_NET_DROP_DIR (out|in|both, default both).
- Arming gate: NOTHING drops in either direction until the decorator has
  FORWARDED the first ENCRYPTED outbound datagram - parse-free check on
  length > 20 with EncryptedChecksum set in the LE flags word at bytes
  4..8. The cleartext handshake always survives and the arming datagram is
  never a casualty; handshake-loss testing belongs to N6's ConnectResponse
  0.333 s retransmit.
- Structurally absent at 0%: WrapIfConfigured returns the raw transport -
  WorldSession's default factory is the only production seam and a normal
  run never constructs the decorator.

Root-cause fix the gate immediately exposed:
- The logoff-confirmation wait in Dispose processed inbound datagrams but
  never pumped the transport, so a lost S2C logoff confirmation was
  gap-detected but its healing NAK never went out. Retail's pump
  (Client::UseTime @ 0x00411C40 -> PacketController::UseTime @ 0x005410D0)
  runs until LogOffServer; the wait now sweeps per processed datagram,
  making the logoff wait the third covered blocking pump (after Tick and
  the handshake loops). A lost C2S logoff REQUEST remains unrecoverable by
  ACE design (arrival-driven NAK; a quiet client is never NAKed - campaign
  section 3 row 1), recorded in the gate header.

Gates:
- tools/run-connected-loss-gate.ps1 (-DropPct 2 -Seed 1): PASS vs local
  ACE - the first automated observation of packet loss in project history.
  Decorator ledger: dropped out=3 in=10 of forwarded out=183 in=496.
  [net-final] resends=2 nak-in=2 nak-out=6 rej-in=0 acks-out=114
  acks-in=119 dup-drop=0 sanity-drop=0 cksum-fail=0 parked=9 reclaimed=0
  uncached-nak=0 cache=1 nakset=0. Every injected loss healed: both
  ACE-driven C2S resend recovery (nak-in=2 -> resends=2) and client-driven
  S2C NAK recovery (parked=9 -> nak-out=6) fired on a real connected
  route, all six checkpoints validated, graceful logout confirmed, ACE
  recorded the transport Disconnect.
- tools/run-connected-world-lifecycle-gate.ps1 (decorator absent): PASS -
  zero behavior change on the no-loss baseline; the gate now defensively
  clears the drop env vars.
- Core.Net Release: 747/747 (737 + 10 N5: decorator determinism/direction/
  arming/structural-absence/env parsing, the 5% seeded WorldSession lossy
  lifecycle with zero message loss both ways + ACE Headroom 256, the
  [net-tick] field pins, the Iteration stamps).
- Full solution Release: 9,763 passed / 5 skipped / 0 failed.

Test-fixture note: FakeAceTransport gains AutoAdvanceOnBlockingReceive so
virtual time can move during the blocking Connect()/EnterWorld() pumps -
with the clock frozen there, a dropped handshake-window datagram could
never be NAK-healed (a fixture artifact, not a transport property).

Campaign section 9 ledger row added (SHA recorded at N6 kickoff).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 16:26:06 +02:00
Erik
5852bdb877 feat(render): Vulkan campaign V11 step 4 — retire GL from CI and gate scripts
Closes out the GL deletion by fixing the CI workflow and developer gate
scripts that still assumed a GL arm existed to compare against, build,
or select via ACDREAM_RENDER_BACKEND.

.github/workflows/headless-portability.yml: linux-graphical's "Verify
actionable unsupported-driver gate" step is deleted outright — it ran
the deleted `ui-studio` CLI verb (Studio was removed at Commit 1) to
prove the GL capability gate rejects Mesa's llvmpipe driver, and there
is no more GL capability gate for any driver to pass or fail. Its test
filter dropped two dead entries (GraphicalCapabilityRequirementsTests,
deleted at Commit 2; StudioWindowTests, already gone). Its package
contract check dropped the libcimgui.so assertion (ImGui's native
bridge, deleted at Commit 1). linux-vulkan's explanatory comment, which
described GL's rejection as the reason no cross-backend pixel diff runs
in CI, is rewritten to explain there is no GL arm left at all. Two dead
src/AcDream.UI.ImGui/** path triggers (that project no longer exists)
are removed from both the pull_request and push filters.

tools/run-backend-differential-gate.ps1 and its dedicated route file
tools/connected-backend-differential.route.txt are deleted: the whole
script's purpose was comparing a GL launch against a Vulkan launch of
the same route, and there is no second arm left to compare. Single-arm
regression checking already exists via run-offline-pixel-gate.ps1's
-Baseline mechanism.

tools/run-portal-churn-soak.ps1 is simplified rather than deleted: its
repeated-portal-churn methodology (within-arm capture comparison,
memory/entity/GPU trend analysis) has value independent of the
GL-versus-Vulkan question it was built to answer for issues #256/#257
before V11. -Backends now defaults to @('vulkan') alone; the doc
comments are rewritten from "step 0 discriminator, run before V11" to
an ongoing single-arm regression soak.

tools/run-offline-pixel-gate.ps1 drops its now-nonfunctional -Backend
parameter (ACDREAM_RENDER_BACKEND has read zero call sites since
RuntimeOptions.RenderBackend was removed at Commit 2 — passing -Backend
gl silently launched Vulkan anyway) along with its GL-escape-hatch
example and every comment that referenced the now-deleted differential
gate. tools/run-connected-world-lifecycle-gate.ps1,
tools/run-offline-vulkan-capture.ps1, and
tools/run-repeat-connected-gate.ps1 keep their (harmless, already
no-op) ACDREAM_RENDER_BACKEND set/clear lines but have their
now-inaccurate "escape hatch" / "GL run" comments corrected to state
plainly that the variable is unread and the line is kept only for the
historical record.

No .cs files touched; `dotnet build AcDream.slnx -c Release` unaffected
(0 warnings, 0 errors, matching the prior commit's build).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 03:06:45 +02:00
Erik
db4426d5ef test(render): Campaign V slice V11 step 0 — the #256/#257 discriminator, both arms
Issues #256 (server-spawned signs and portals go invisible after repeated
portal runs, while staying interactive) and #257 (working set grows to
~1.5 GB over the same session) were observed together in one long live
Vulkan session, and both filings demanded the same thing before V11 deletes
the OpenGL backend: run the churn on GL too. Vulkan-only growth or drift
would mean the new arm's resource lifecycle is broken, and deleting its only
reference implementation while that was true would be wrong even with the
cutover signed.

So the discriminator is built and run first, and it can stop the slice.

tools/run-portal-churn-soak.ps1 generates a route of N cycles over three
portal-bearing stops taken from the two existing connected routes, runs it
once per backend from one binary, and measures three things the existing
instruments do not measure together:

  * working set and private bytes, sampled from the OS every two seconds and
    joined to each checkpoint by timestamp -- the client's own snapshot has
    no view of its own working set, which is exactly #257's quantity;
  * the published-versus-live pair already in the checkpoint JSON, because
    "alive in the object table, gone from the presentation" is #256's whole
    symptom and a drift between those halves at the SAME stop across cycles
    is what would show it;
  * a within-arm capture comparison -- cycle 1 against cycles 10, 20 and 30
    at a pinned viewpoint -- plus a difference map and a row histogram,
    because a number cannot tell an absent object from a walking NPC and the
    map can.

Every teleloc carries the identity quaternion so the heading repeats, and
the four determinism levers the differential gate forces are forced here for
the same reason: an unpinned sun would swamp the signal.

Result at 90 transits per arm, 91 checkpoints, zero errors, graceful exits:
neither arm reproduces either symptom. Working set means agree to 1 MiB
(GL 1864, VK 1863) and warm-half drift is NEGATIVE on both (-48.0, -27.0).
GPU accounting is exactly constant per arm. worldEntities holds 10,382 at
all thirty cycles on both. The difference maps show every building, the
portal, the statue and the treeline still drawn at cycle 30.

That refutes the one outcome that would have blocked V11, and it does not
identify the pre-existing bug -- so both issues stay OPEN with the negative
recorded, and the follow-up named: walked portal transits rather than
/teleloc, which do not take the same path into the transit state machine.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 23:24:45 +02:00
Erik
122fe8a7e2 feat(render): Campaign V slice V10 — Vulkan becomes the default backend
THIS CUTOVER AWAITS THE USER'S VISUAL SIGN-OFF. It is not complete. Section 7
of the campaign plan names the V10 sign-off as the only required user stop
besides gate failures, and it has not been given. This commit flips the default
and runs the battery so that the sign-off has evidence in front of it.

ROLLBACK, one line: `git revert` of this commit. It restores the GL default,
the pre-V10 escape-hatch polarity and the gate scripts' inherited backend
together; nothing else has to move with it.

An unset, empty or unrecognised ACDREAM_RENDER_BACKEND now yields
RenderBackendKind.Vulkan. Only `gl` or `opengl`, case-insensitive, selects
OpenGL. The polarity of the typo case flipped with the default and on purpose:
before V10 an unrecognised token had to land on GL because Vulkan was dark and a
typo must never silently start a backend that cannot draw; after V10 it has to
land on Vulkan for the same reason read the other way, because GL is the backend
V11 deletes. `opengl` is honoured beside `gl` because an escape hatch exists to
be found.

Three gate scripts follow the flip. run-offline-pixel-gate.ps1 gains -Backend
(default vulkan) and now FORCES all four determinism levers — backend, day
group, world day fraction, sky phase — plus ACDREAM_MSAA_SAMPLES=0, instead of
inheriting any of them. run-repeat-connected-gate.ps1 and
run-connected-world-lifecycle-gate.ps1 CLEAR ACDREAM_RENDER_BACKEND rather than
setting it, so what they exercise is the process default and an ambient override
in a caller's shell cannot make a GL run wear the default's report.

TEST PIN UPDATED, flagged as required: RenderBackend_DefaultsToGl becomes
RenderBackend_DefaultsToVulkan, and RenderBackend_AnythingElseStaysOnGl splits
into RenderBackend_SelectsGlOnlyForTheEscapeHatchTokens and
RenderBackend_AnythingElseStaysOnVulkan. Five cases replace two. No other test
is touched, weakened or deleted.

AD-46's divergence-register row moves from "dormant until the V10 cutover" to
live, in this commit, per the same-commit register rule.

Battery, all on the new default:

  complete Release suite    9,222 passed / 5 skipped / 0 failed (9 projects)
                            +5 against the pre-flip 9,217; the +5 are this
                            slice's own escape-hatch cases
  #250 family, singly       4/4 pass (none failed in the whole-suite run)
  repeat connected gate     PASS 3/3 on both columns
  world-lifecycle route     PASS, 0 failures, both sessions graceful at exit 0
  validation layer          inserted at instance AND device level by the loader,
                            zero errors and zero warnings, real frame captured
  GL escape hatch           verified by two offline launches: 4.3.0 Core Profile
                            Context, bindless present, exit 0

Every connected launch in the battery reached Vulkan with no environment
variable set, which is the flip itself under test rather than an assertion
about it.

THE PIXEL GATE IS NOT MET, AND WAS NOT RELAXED. Vulkan against a GL-era capture
taken at this commit through the escape hatch, MSAA off and both clocks pinned:
1.099e-03 masked / 3.764e-02 whole-frame, against a 0.001 threshold. 97.9% of
the difference is in the treeline band, and the masked residual of 619 px — set
against a same-backend control of 10 px — sits entirely on the silhouettes of
distant alpha-blended scenery. That is AD-46's registered population; section
5.5.19 measured the same quantity at 497 px / 8.8e-04. Below the band the two
backends are photometrically identical: mean luminance differs by 0.01 of 255.
No baseline was regenerated and no mask or tolerance was widened.

Two instrument findings are recorded in section 5.5.23. The offline gate's sky
mask is still load-bearing — this slice tried retiring it on the reasoning that
V7's clock pins had made it obsolete, and the control refuted that: two launches
of the same binary still differ by 1,011 px on GL and 482 px on Vulkan, almost
all of it in the band. The default went back to 280 with the measurement written
into the script's help. And the repeat gate's desktop witness needs an
uncontested primary monitor: a first attempt reported 1/3, and the two failing
grabs turn out to be a web browser and Discord composited over the client rect,
not a blank frame — the client's Vulkan capture rendered in all six runs.

Nothing GL, ImGui or Studio is deleted. That is V11's scope and it is untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 22:32:25 +02:00
Erik
777f60708d ci(render): make V9's first CI run green on both operating systems
The lavapipe job did the thing it was built to do on its first attempt.
It accepted a Cpu device at API 1.4, created a device and read pixels
back, captured a real frame, and exited 4 when a feature was forced
unsupported. Three other things were red, and none of them were the
Vulkan backend.

The shader-freshness step aborted for two separate Linux faults in the
compiler tool. Disposing the Silk.NET API container unloads the native
module, and dlclose-ing libshaderc_shared.so leaves glslang's
process-level teardown running against unmapped code. Bisected with a
four-mode probe on Ubuntu 24.04: GetApi, CompilerInitialize and
CompilerRelease each exit 0, and adding only the container Dispose turns
the exit into SIGSEGV. That is the 134 CI reported. shaderc's own handles
are still released; the container is not, because the module's lifetime
is the process's and the process is one statement from returning.
Separately, a portable dotnet build leaves the native under
runtimes/linux-x64/native/ and makes reaching it Silk.NET's probing
problem, which it solved on a local Ubuntu 24.04 and did not solve on the
runner. The script now publishes the tool for the host RID, so the native
sits beside the assembly where AppContext.BaseDirectory finds it, and
checks for it by name so a regression says which file is missing rather
than which names failed.

With both fixed, the question section 5.5.20 left open has an answer:
Linux shaderc and Windows shaderc agree byte-for-byte at the pinned Silk
2.23.0. Eighteen of eighteen .spv identical, manifest identical. The byte
comparison stays a byte comparison.

The Windows leg of portable-headless was running sudo apt-get. That step
is older than this campaign - it is red in the 2026-07-27 main run too -
and it was misplaced rather than mis-conditioned. Nothing in that job
opens a display or links GL, and the graphical jobs that do call xvfb-run
take it from the runner image, so the step is deleted rather than
guarded. Every remaining step in the two-operating-system matrix is pwsh;
every bash step now lives in an ubuntu-only job.

The last failure was ours in a quieter way. WaitForCharacterLogOff-
Confirmation expressed its deadline only as a CancellationTokenSource,
whose timeout is published from a thread-pool timer callback, so on a
saturated pool the token stays unsignalled past the deadline while the
loop keeps draining items that are already queued. That is the case the
method exists to bound. Reproduced by pinning the suite to two CPUs on
Linux, which failed 2 of 6 where four CPUs and sixteen were clean, and
where CI failed 3 of 3. The drain now reads the deadline off the
monotonic clock as well; the token still bounds the asynchronous wait.
Ten of ten clean under the same pin. The test is untouched. Filed as

Release build green. App tests 4,152 / 3 skipped against the same 4,152 /
3 measured at base 32f9bcfa. Core.Net 600 / 600.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 21:52:53 +02:00
Erik
a13cff884f ci(render): Campaign V slice V9 - the Vulkan gate runs on lavapipe
The first CI job in this project's history that renders a frame.

The whole row rests on a decision V6g already made and paid for. When
section 5.5.8 cut set 0 from ten dynamic storage descriptors to four, four
was not merely under the RX 9070 XT's eight - it is Vulkan's guaranteed
minimum, so no conformant device can fail the layout. That is what makes a
software-device row possible at all. Every other requirement was then
checked against Mesa's lvp_device.c rather than assumed, and all seventeen
features the gate demands are true on lavapipe - including
samplerAnisotropy, which V7 made load-bearing eight commits ago and which a
software rasterizer would have been entirely within its rights to decline.

Three things had to exist before the job could:

1. The harness could not stop. VulkanBringUpHost presents until its window
   closes, which is right at a desk and impossible in CI, where nothing ever
   closes a window. ACDREAM_VULKAN_PROBE_FRAMES gives it a budget; unset or
   malformed is zero, which keeps the interactive behaviour, so no existing
   invocation changes. The budget never cuts the capture short - the loop
   stays open until the screenshot has been attempted - because a run whose
   entire product is a PNG must not be able to exit green with an empty
   artifact directory. The decision is a pure static method, tested without
   a window or a driver.

2. tools/compile-shaders.ps1 was Windows-only and nobody had noticed,
   because nothing had ever run it anywhere else. It built its paths from
   embedded 'src\AcDream.App\...' literals; a backslash is a separator on
   Windows and an ordinary filename character everywhere else, so on Linux
   that is one long nonexistent file name.

3. The report's jq paths were invisible to the compiler. Renaming a record
   property or swapping the enum converter would have left every test green
   and turned CI red on someone else's branch days later, with a failure
   that reads like a driver problem. VulkanCapabilityReportContractTests
   pins the exact strings the job greps and pins its packed-version
   arithmetic against VulkanApiVersion's own unpacking.

The job, eleven steps: install lavapipe and Xvfb; record vulkaninfo as
evidence; publish linux-x64; run the Gpu.Vk tests on a second operating
system; probe the gate under a 24-bit Xvfb screen (the default is 8-bit,
which leaves the X11 WSI without a usable visual) and assert an accepting
verdict on a Cpu device at API >= 1.3 with a clean active probe; assert the
captured PNG is a real frame by IHDR dimensions and byte count; re-run with
ACDREAM_VULKAN_FORCE_UNSUPPORTED=timelineSemaphore and assert exit 4 with an
actionable refusal; recompile the shaders and compare. Artifacts upload on
always(), so a red run ships its own diagnosis.

The .spv step is what ties the committed binaries to their sources. The
existing App test hashes GLSL against the manifest, which catches "edited a
shader, forgot to recompile"; nothing caught a stale or hand-edited .spv.
Verified on Windows before shipping: 19/19 artifacts byte-identical to a
fresh compile, zero drift.

No GL-versus-Vulkan pixel compare, for two independent reasons recorded in
section 5.5.20: linux-graphical asserts exit 4, so there is no left-hand
side, and the probe renders synthetic scenes rather than the DAT world CI
cannot have. The two jobs now say something sharper than a pixel diff would
have - on the same software Mesa stack, GL is refused and Vulkan is accepted
and draws. Physical Linux GPU and Wayland rows stay deferred on the Slice L
precedent; no hosted runner offers either.

Gates: Release build green, zero errors. App tests 4,152 / 3 skipped against
a 4,134 / 3 baseline at this branch's base (9b7f4343) - eighteen new, all
from this slice. Workflow validated by a real YAML parse plus an Actions
schema check and bash -n over all nine extracted run blocks; no actionlint
was available locally and none was downloaded. The job itself has not run:
its first execution is the CI run this commit triggers, and the V9 row stays
partial until that is green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 20:10:01 +02:00
Erik
1f25a60999 fix(diag): Campaign V slice V7 commit 2 - pin the world clock, because the route never did
THE ROUTE'S TIME PIN NEVER HELD, AND EVERY V7 NUMBER SO FAR WAS TAKEN THROUGH IT.

connected-backend-differential.route.txt opened by pressing
AcdreamCycleTimeOfDay three times, on the stated theory that the cycle walks
live -> 0.00 -> 0.25 -> 0.50 and lands on noon. The mechanism underneath is
WorldTimeService.SetDebugTime, and SyncFromServer clears it -- deliberately,
because that setter is the /time slash command and the command is meant to be a
look-at-dusk-for-a-moment affordance rather than a mode. There is even a test
pinning that behaviour: WorldTimeDebugTests.SyncFromServer_ClearsDebugOverride.
ACE sends TimeSync every few seconds. The clock was therefore un-pinned again
long before the route reached its first stop, on every run this campaign has
taken, including V6m's smoke pair.

The Dereth clock does not only move the sky. It moves the SUN, so it moves the
directional term of every lit surface in the scene.

MEASURED, rather than argued. A probe route captured each stop TWICE, 45 seconds
apart, in the same run on the same backend:

    GL,     Holtburg,      capture 1 vs capture 2:  205,772 px   22.33%
    Vulkan, Holtburg,      capture 1 vs capture 2:  218,732 px   23.73%
    GL,     Facility Hub,  capture 1 vs capture 2:  108,795 px   11.81%
    Vulkan, Facility Hub,  capture 1 vs capture 2:  130,206 px   14.13%

One backend, one stop, nothing moving, and a fifth of the frame changes while
you watch. No cross-backend number means anything against that noise floor, and
the cross-backend numbers taken during that probe run were duly absurd -- 56% at
Holtburg, where the two launches happened to be at different times of Dereth day.

THE FIX IS A PIN THAT OUTRANKS THE SERVER CLOCK AND SURVIVES SYNC.

WorldTimeService.PinnedDayFraction is a nullable day fraction that wins over both
Calendar.DayFraction(NowTicks) and SetDebugTime, and that SyncFromServer does not
touch. ACDREAM_WORLD_TIME -> RuntimeOptions.PinnedWorldDayFraction ->
WorldEnvironmentController, which writes it once: the Runtime environment owner
and its clock are session-scoped, so one write outlives every teleport and every
reveal generation. Values outside [0, 1) are REJECTED rather than clamped -- a
day fraction of 12.5 is a typo, and silently pinning the world at it would be
worse than ignoring it.

Unset is the default and every ordinary run. The calendar DATE still advances,
which is intentional: the date drives day-group selection, and ACDREAM_DAY_GROUP
already pins that. The differential gate forces the pin at 0.5 -- noon, which is
what the three presses were aiming at -- on both launches, and the route's
presses are deleted rather than left in as decoration.

This is instrument determinism on the footing of ACDREAM_DAY_GROUP and V7's
ACDREAM_SKY_PHASE_SECONDS, not a workaround: it is off by default, nothing in the
shipping client reads it, and the alternative was to keep measuring two backends
through a fifth of a frame of sunlight.

WHAT IT MOVED. The same three-stop route, same commit otherwise, before and after:

    holtburg_town           9.05%  ->  2.86%      (83,438 -> 26,330 px)
    facility_hub_interior  12.16%  ->  0.78%      (112,075 -> 7,176 px)
    aerlinthe_island       23.09%  ->  6.82%      (212,824 -> 62,892 px)

The interior stop is the headline. V6m recorded it as a route defect on the
theory that the indoor spring-arm camera settles to different distances in two
runs; that theory is now refuted. The camera was fine. The interior was lit
differently because the sun had moved, and with the sun held still the stop drops
by a factor of 15 to 0.78% -- close enough to the 0.001 threshold that its
remaining population is worth naming rather than guessing at. No route change was
needed and none was made.

WHAT REMAINS, per the difference maps, all of it now attributable by eye:
the animated portal beside the Holtburg stop; distant scenery foliage; wandering
NPCs and a chimney smoke plume, which are animation and emitter phase; the vitals
readouts, whose stamina and mana genuinely regenerate at different rates across
two logins minutes apart; and, at Aerlinthe, a dense low-magnitude speckle in a
scene whose mean luminance is 28/255 -- half of its differing pixels are exactly
delta 3, one step over a tolerance that is absolute rather than relative.

Gates. Release build green. App tests 4,134 passed / 3 skipped (one new: the
day-fraction range check); AcDream.Core.Tests WorldTimeDebugTests 6/6, including
the two new ones that assert the pin survives a sync and outranks the transient
override. GL offline pixel gate against the pre-slice tree: 2.66e-05, 15 pixels
of 563,200, inside the documented 9-31 band -- GL did not move.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 19:34:13 +02:00
Erik
ad5f8b68dc fix(render): Campaign V slice V7 commit 1 - world anisotropy, and the sky's second clock
Two changes, one measurement. The V6m smoke pair put GL versus Vulkan at
Holtburg at 18.52% of the frame differing at tolerance 2 with MSAA off. The same
stop on the same instrument now measures 9.05%, and the two populations these
address are gone from the difference map rather than merely smaller.

1. THE WORLD ATLASES WERE SAMPLED WITHOUT ANISOTROPY ON VULKAN, AND WITH THE
DEVICE MAXIMUM ON GL.

RhiWorldTextureArray -- the backend-neutral shared object/material atlas, and
the only IWorldTextureArray the Vulkan arm ever constructs -- registered its
clamp and repeat slots with GpuSamplerDescription.WorldClamp/WorldRepeat as
written, which carry MaxAnisotropy 1. The GL arm asks for the driver's own
GL_MAX_TEXTURE_MAX_ANISOTROPY twice over: ManagedGLTextureArray sets
GL_TEXTURE_MAX_ANISOTROPY on the image, and the two sampler objects its resident
bindless handles are built from (OpenGLGraphicsDevice.WrapSampler/ClampSampler)
set it again, which is the one that actually wins.

V6i-2 knew it was asking for 1 and said so in a comment -- "the world arm that
draws through these arrays is the next slice, and it is the one that can gate a
filtering change visually." That slice was V6j, the gate is V7, and this is it.

Retail settles the question rather than the GL arm settling it.
RenderDeviceD3D::SetDefaultD3DStates (0x005a3800) loops all sixteen sampler
stages and issues SetSamplerState(stage, 0xA, this->m_D3DCaps.MaxAnisotropy) at
0x005a4230. 0xA is D3DSAMP_MAXANISOTROPY and the argument is the device's
reported cap, not a setting -- so "as much anisotropy as this device has" is
retail's own rule, the GL arm is faithful to it, and asking for 1 diverged from
retail as well as from the shipping backend. No divergence-register row is owed
in either direction: this retires a Vulkan-only gap and lands on retail's value.

The fix asks for a ceiling rather than reading a limit back, because the pinned
RHI contract (plan section 3.3) carries no anisotropy field and is frozen. It
does not need one: VulkanGpuSampler already clamps MaxAnisotropy to
VkPhysicalDeviceLimits.maxSamplerAnisotropy, Vulkan guarantees that limit is at
least 16 wherever the samplerAnisotropy feature is supported -- which this
backend requires -- and 16 is where every desktop driver caps. The request and
the GL arm's read therefore land on the same number.

What it was worth, from the difference map at the same stop: the roof shingles
of both Holtburg cottages, which had been dense hatching across the whole
surface, and the stone courses of the near building are now black. Measured as
high-frequency energy (mean absolute neighbour difference, GL versus Vulkan) the
right-hand roof went from visibly blurred to a ratio of 0.999 and the wall to
1.023; every other textured region in the frame is between 0.99 and 1.02.
Grazing-angle surfaces are where anisotropy is the whole difference, which is
why a roof was the loudest thing in the frame.

2. THE SKY HAS TWO CLOCKS AND ONLY ONE OF THEM WAS PINNABLE.

ACDREAM_DAY_GROUP and the route's AcdreamCycleTimeOfDay presses pin the Dereth
date, which chooses the day group, the keyframe and the sun angle. The cloud
sheet does not read that clock: SkyRenderer accumulates TexVelocityX/Y against
DateTime.UtcNow minus its own construction time, by design, because retail's
clouds drift with real time regardless of the date. Two launches minutes apart
therefore cannot agree about where the clouds are no matter what the route does,
and the V6m smoke measured the cost -- 89% of its 18.52% sat in the top 240 rows.

ACDREAM_SKY_PHASE_SECONDS (RuntimeOptions.SkyAnimationPhaseSeconds ->
SkyRenderer.AnimationPhaseSecondsOverride) replaces that elapsed-seconds value
with a fixed one. Unset -- the default, and every ordinary run -- keeps the wall
clock, so nothing a user or the offline gate sees changes. The differential gate
forces it on both launches alongside MSAA and the day group; the offline gate
keeps its top-280 mask, because a same-commit GL pair still has the sun to
disagree about.

This is instrument determinism on the same footing as ACDREAM_DAY_GROUP, not a
workaround: it is one input to a UV offset, it is off by default, and no shipping
path reads it. The alternative on the table was -MaskTopPixels, which would have
permanently blinded the campaign's strictest instrument to the entire sky -- one
of the five surfaces the offline gate already cannot see. Rows 0-32 of the
Holtburg pair went from 23,090 differing pixels to 1,211, and what remains up
there is roof and portal rather than cloud.

WHAT THE SAME PAIR STILL SHOWS, unattributed and carried to the next commit: the
distant treeline, the player and the NPCs, and the animated portal. The portal is
phase and expected. The treeline is not filtering -- sharpness now matches within
5% and a shift search finds no sub-pixel offset -- and the two runs entered the
world at different last-logout positions (0xC95B0001 versus 0x09040008), so the
far-tier streaming history differed. That is the next thing to prove or refute.

Gates. Release build green. App tests 4,133 passed / 3 skipped against the
4,132/3 baseline (one new: the sky-phase parse). GL offline pixel gate against
the pre-change tree: 2.31e-05, 13 pixels of 563,200, inside the documented 9-31
band -- GL did not move. One offline Vulkan run with VK_LAYER_KHRONOS_validation
proven inserted by the loader: zero validation errors, zero warnings. Full
three-stop differential recorded at artifacts/v7-diff-c1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 19:14:08 +02:00
Erik
a99f517ec2 feat(tools): Campaign V slice V6m commit 2 - the backend differential gate
tools/run-backend-differential-gate.ps1 is V7's instrument, plus the route it
runs. It executes the SAME connected route twice - ACDREAM_RENDER_BACKEND=gl
then =vulkan - pairs the screenshots by name, compares each pair with
compare-screenshots at the plan's pinned tolerance 2 / fraction 0.001, and
prints one verdict table with a JSON report beside it.

ACDREAM_MSAA_SAMPLES=0 IS FORCED ON BOTH LAUNCHES. Plan section 5.5.16 measured
what leaving it on costs - 8.83% of the frame at 4x, essentially all of it
hugging foliage and silhouette edges, which is two drivers' sample patterns and
not a renderer divergence. Forcing it on one side only would be worse than
either. ACDREAM_DAY_GROUP is pinned on both, and the route additionally pins the
client-only time-of-day override so the Dereth clock cannot drift between two
launches minutes apart.

The desktop witness and its three guards are lifted from the repeat-run gate
rather than reinvented, because that gate learned them the hard way: a blank
frame reads back as a valid PNG (section 5.5.2), so a second instrument that
shares nothing with the renderer decides whether a run is worth comparing at
all; a locked screen or an overlapping window is caught by the grab's size; and
stray input into a foregrounded window moves the camera off the pinned stop, so
the client log is scanned for camera-affecting actions and the run is aborted
rather than reported. Both runs close through WM_CLOSE - a hard kill leaves ACE
holding the session about three minutes and the second launch is the one that
pays for it.

connected-backend-differential.route.txt is written for two-launch determinism,
which is stricter than either existing route needed to be: identity quaternions
on every teleloc so heading is pinned, no movement between arrival and capture,
and no checkpoint verb (the ownership ledger has its own gate). Three stops -
Holtburg town, an interior EnvCell at the Facility Hub, Aerlinthe's island and
water edge. The interior stop is the durable fix section 5.1 names for the
campaign's oldest coverage gap: no connected route has ever visited one, which
is why EnvCellRenderer had no automated coverage on either backend.

ONE SMOKE PAIR was run, on the first stop, and it is reported honestly rather
than tuned. GL versus Vulkan at Holtburg with MSAA off: 170,697 of 921,600
pixels differ, 18.52%, maximum channel delta 255. Attributed with a difference
map:

  * 89% of it is in the top 240 rows. Masking the offline gate's top 280 rows
    takes the pair to 11,480 of 563,200, 2.04%. That band is the scrolling
    cloud sheet, which advances with WALL time and not with the pinned Dereth
    clock, plus the treeline behind it.
  * Masking the animated portal beside the character as well takes it to 6,190
    of 529,450, 1.17%, mean channel delta 0.49. A portal's scrolling texture is
    phase, like an emitter's age.
  * What remains is thin outlines on silhouette edges throughout the frame,
    plus the vitals readouts, whose stamina and mana genuinely advanced between
    the two logins.

So the distance to V7's 0.001 is about 12x with the two known phase populations
removed, and V7 owns closing it. -MaskTopPixels exists for that conversation and
DEFAULTS TO 0: the gate is a strict identity check on the whole frame unless
someone deliberately asks otherwise, per section 7.1 rule 2.

Both files are ASCII with CRLF, matching the other tools. That is not cosmetic:
PowerShell 5.1 reads a BOM-less .ps1 as ANSI, so a UTF-8 em dash inside a
double-quoted string is a parse error, which is how the first version failed.

Gates. Release build green; App tests 4,132 / 3 skips. No product code changed,
so commit 1's pixel, connected and validation gates stand unchanged - and the
smoke run is this commit's own evidence, since it drove a complete connected
Vulkan session end to end with a graceful exit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 18:34:43 +02:00
Erik
f84eef3256 feat(render): Campaign V slice V6j commit 2 - Dereth draws on Vulkan
The three world renderers' submission arms, both pass executors, and the
composition that reaches them. This is the unit three predecessors stopped at.

What it produces. ACDREAM_RENDER_BACKEND=vulkan on the offline scene renders
terrain with blended textures and road overlays, the water edge, static world
meshes, procedural scenery, and the complete retained UI - the same frame the GL
pixel gate captures, from the same camera, minus the sky. artifacts/v6j-vk2.

The shape, and why it is not V4c's. Section 5.5.6 chose option (B) after NVIDIA
rendered the V4c binary 10/10 where AMD's GL stack did not: GL keeps its raw
world path through to V10 as a documented fork confined to the submission seam,
and the RHI world path ships on Vulkan. So V4c's and V4d-2's content returns as a
SECOND arm rather than a replacement. The GL arm issues the same GL statements in
the same order against the same objects; the encoder arm lives in three .Rhi.cs
partials and is entered by one branch per submission site.

Three differences from V4c, each because the tree moved under it. There is no
binding-9 texture table - V4t put the slot on the device and Vulkan binds set 2,
so the arm that used to intern bindless handles simply has nothing to do. The
pipelines carry the device's sample count rather than 1, because Vulkan requires
rasterizationSamples to match the pass and alpha-to-coverage is a no-op at one
sample. And no renderer opens a pass.

That last one is structural, not tidiness. Under MSAA the frame's one backbuffer
pass resolves into the swapchain image and stores DONT_CARE into the multisampled
scratch, so a second pass declaring Load would load undefined contents; the
backend also permits one open pass per frame. VulkanWorldScenePhase therefore
opens the pass, publishes the encoder on VulkanWorldPassScope for exactly the
span of the inner WorldSceneRenderer, and every renderer borrows it.

Three sections are frame-global on GL and cannot be on Vulkan: the SceneLighting
UBO, the per-cell clip regions, and the terrain clip block. GL binds each to a
global binding point and every consumer inherits it. Vulkan binds a descriptor
set per draw, and a renderer's own binds are what select the scope those sections
must land in - so their writers PUBLISH into WorldFrameSections and each renderer
binds them inside the pass, after its own binds. SceneLightingUboBinding's
per-flight-slot buffer pool disappears with it: a ring allocation is already
distinct memory that lives until the frame retires, which is the property the
pool existed to provide.

Both pass executors became backend-neutral rather than gaining twins. Everything
they do is delegation to a renderer except four concerns - the clip-frame
publication, the doorway scissor, gl_ClipDistance enablement, and retail's
interior depth clear - so those four move behind IWorldPassSurface and retail's
ordering, which is what these classes are actually for, is written once. The GL
implementation issues the statements the executors used to issue inline.

Clip distances are no-ops on the Vulkan arm, and that is safe rather than a
divergence: Vulkan activates every element the shader declares, and all three
world vertex shaders already write 1.0 into every slot past the active count.
The interior depth clear becomes vkCmdClearAttachments, reached through the scope
so the pinned contract stays frozen and the backend-only verb stays in the
backend. The hook for it was already committed at V6i-3 with a cref to a type
that did not exist yet; it exists now.

The collision-wireframe DebugLineRenderer is composed as null on the Vulkan arm.
DrawAndPublish flushes it INSIDE the world phase and it opens its own pass, which
the one-pass rule forbids. The toggle is DevTools-only and DevTools is not
composed there, so nothing is lost - composing it would throw on the first
wireframe frame rather than silently misdraw.

Two seams widened rather than invented. GameWindowGraphics answers whether the
backend has a world-pass seam, because the three composition phases that need it
already borrow that handle and "does this backend work that way" is what the type
exists to answer. And MeshSourceReady replaces the anyVao != 0 gate with the same
question in backend-neutral form - V6i-3 published HasStores for exactly this -
so the predicate evaluates identically on GL.

What is NOT here, and is expected. Sky and weather are still raw GL (V4f), so the
Vulkan frame's sky is the atmosphere fog clear. Particles (V4e), the paperdoll and
appraisal viewports and the portal depth mask (V4g) likewise. The executors
already accepted all of them as absent.

Gates. Release build green. App tests 4,112 passed / 3 skipped, the unchanged
baseline; complete Release suite 9,175 / 5. Strict GL offline pixel gate against
847f14ae: 5.50e-05, 31 differing pixels of 563,200, inside the documented 9-31
band and 18x under the threshold. Characterised rather than accepted, because 31
is the band's top: cross-commit pairs measured 21, 29 and 31 while same-commit
controls measured 12 and 20, and maximumChannelDelta is 46-52 in every comparison
INCLUDING the pure controls - so the few large-delta pixels are a property of the
capture, and a cross-commit pair at 21 against a same-commit pair at 20 is not
what a systematic shift looks like. GL connected repeat gate at 3 runs: 3/3
RENDERED on the desktop witness and 3/3 on the client capture. One offline Vulkan
run with VK_LAYER_KHRONOS_validation proven inserted by the loader: zero
validation errors, zero warnings, a captured world frame, and a graceful close.

Coverage gap, stated rather than assumed. The offline scene is a fixed outdoor
view, so EnvCellRenderer's Vulkan arm draws nothing in it - dungeon interiors are
half of this slice and are unproven by anything automated, exactly as they were
for V4c. The deferred-alpha path and the doorway scissor are likewise untouched
by this scene. They join the accumulated user-gate debt in plan section 5.1.

No divergence-register row: no retail-facing behaviour changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 15:46:54 +02:00
Erik
935f4dc3d9 feat(render): V6e — move the world mesh's texture lookup to where Vulkan can express it
Campaign V slice V6e, first of three. mesh_modern is the shader every world
static, every piece of scenery and every EnvCell surface draws through, and it
was one of the four production pairs the SPIR-V toolchain still refused.

The blocker was a varying. Since V2 the vertex stage looked a batch's table slot
up in the binding=9 handle table and forwarded the resulting 64-bit
GL_ARB_bindless_texture handle to the fragment stage as a `flat uvec2`. That
works on GL because a bindless handle is just a number a shader may carry
anywhere. It cannot work on Vulkan at all: the equivalent object is a descriptor
in set 2, and a descriptor is not a value a stage can hand to another stage. So
what travels between the stages is now the SLOT — a `flat uint` — and the
fragment stage does the lookup at the point of sampling.

That relocation needs one shared idea, because the two backends disagree about
what the lookup IS. `ACDREAM_SAMPLE_ARRAY(slot, uvw)` asks the dialect-neutral
question — "sample table slot N" — and expands to
`texture(sampler2DArray(gTextureTable[slot]), uvw)` under GL and to
`texture(uTextures[nonuniformEXT(slot)], uvw)` under Vulkan. It is deliberately
a SAMPLING macro rather than a sampler-returning one: `nonuniformEXT` belongs on
the indexing expression itself, and binding the result to a local
`sampler2DArray` first is exactly where an implementation is free to drop it.
That is the same shape V6d already used for the retained UI's 2-D reads, and it
now covers the array reads the world path needs.

`ACDREAM_TEXTURE_NONE` lands alongside it, unused here and used by the next
commit. GL can ask "does this slot hold a texture" of the payload, because an
unregistered slot holds the null handle; Vulkan cannot, because set 2 is opaque
and reading an unwritten element of a partially-bound array is undefined rather
than zero. The sentinel moves that answer into the index, where both dialects
test it identically.

On GL nothing about the sampled result changes — the same slot resolves to the
same handle to the same texel. The SSBO read simply happens one stage later,
and `flat` keeps it one scalar load per primitive rather than per fragment.

Also: RenderBootstrap has been loading mesh_modern without common.glsl since V2,
which cannot have linked — `ACDREAM_UBO_SET` sits inside a layout qualifier
there. The UI Studio path is the only caller. One argument, same pair, same way
WorldRenderComposition has always loaded it.

Gates: Release build clean; App tests 4,057 passed / 3 skipped (baseline);
offline pixel gate against 95f8c25f differing fraction 3.37e-05 (~19 px of
563,200), inside the documented 15–23 px same-commit noise band and ~30x under
the 0.001 threshold. mesh_modern is the shader that gate covers most heavily,
so this is the strongest automated evidence any V6e commit gets.

Manifest: 4/9 pairs compile (debug_line, mesh_modern, ui_text, vk_probe).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 09:21:22 +02:00