104 KiB
Asura: cut map
Status: cut map, Imagination pass 2, 2026-09-25. Cuts 2, 2a-i, 2a-ii-a and
2a-ii-b have landed on CultLib main; Progress below records each. 2a-ii-c (the
CultMath release) has not been cut.
docs/target.md owns the ends. This document owns the means. Where this map and
the Body disagree, the Body wins and this map is stale. The map lives on main
of GameCult/Asura.
Pinned HEADs (every file:line below is against these):
| Repo | Ref | SHA | Note |
|---|---|---|---|
| Asura | main |
5f0f6bd |
target with the rulings on Q1–Q9, archetypes, the erosion filter and invariant 8 |
| CultLib | origin/main |
29e50ad |
Local main in F:\Projects\CultLib is 15 commits behind (d110d9d). The main checkout sits on hands/cultmath-erf. Work from origin/main in a worktree. |
| CultLib tags | cultmath-unity-v0.2.4 / cultlib-unity-v1.0.60 |
6d5e209 / 45c2f40 |
both are ancestors of 29e50ad |
| Aetheria | origin/master |
9b85211f |
mainline, 2026-09-17; pins cultlib-unity-v1.0.59 and cultmath-unity-v0.2.3 |
| Aetheria | origin/codex/fire-control-12 |
ab14552a |
In flight: 218 commits ahead of master and 2 ahead of the local checkout (14ee2a52). It pins cultlib 1.0.60, cultmath 0.2.4 and caching 1.4.0. Do not touch F:\Projects\Aetheria's tree. |
Rulings: the target's dated rulings, including "Rulings on the cut map's questions (2026-09-25)". Summary:
- Q1: Aetheria cuts go on master, after fire-control merges.
- Q2: delete the outpost and recon assets.
- Q3: seed from a local generator, archetype by seed, no compatibility path.
- Q4: bodies are archetypes, material (rock | ice) × activity (dead | active), with hydraulic erosion switched on per body, using Johansen's erosion filter. Phacelle noise and simplex noise with an analytic gradient go in CultMath; the filter goes in Asura.
- Q5–Q9: as recommended.
The answered questions are recorded under "Operator questions".
Later operator additions the same day:
- aeolian dunes: "1-abs(snoise(p)) gives nice dune contours", used "to push the terrain really far at really low frequency to influence the silhouette";
- invariant 8: "make sure we have analytic derivatives flowing down every level".
Progress (Self keeps this current):
-
Cut 2: CLOSED and landed on CultLib
mainat4163001(a--no-ffmerge ofhands/asura-surface-nets,29e50ad..d64de1b; the merged tree equals the one Soul verified).-
Three Soul passes. Pass 1:
- Stryker never mutated
Extract; - there were two orientation authorities;
- a float guard gave answers worse than exact;
- the survivor triage was wrong.
Pass 2: two false doc claims and weak
Equalsand validation. Pass 3: promises confirmed; byte-identical output over 31 fixtures across the closing refactor. - Stryker never mutated
-
Final Stryker on the scoped diff: 96.69%. SurfaceNets has 6 survivors, all equivalent (5 exception-message strings, plus
<= 0on a ±1 table); QuadMesh and QuadNormals have 0. -
Follow-ups:
- (a)
docs/mutation-testing.md:94-119describes the deletedTryGetOtherEndpointin the present tense. Mark it as history. - (b) Soul pass 3 left two
KEEP=1work directories on Yggdrasil (~/eureka-verify/work/CultLib-d64de1b69d-*), which are root-owned from the container. Removing them needs the operator or a sudo-capable session.
- (a)
-
The 7
GeometryDocumentsTestsfailures: CultLibe420410(CultNet selection, cut 1, commit 0) added registry check D11, which refuses[CultReference]onstringmembers. Geometry has three of them (CultGeometryDocuments.cs:156, :231, :316). The tests passed atb3d9cf7and fail ate420410. CultLiba6720b62(2026-09-30, merging0479304d..9ea134ac) makes the three membersCultRecordRef<T>; the commit states the wire bytes are unchanged. Q13 ruled by the operator, 2026-10-02: "a6720b62 answers Q13".GeometryDocumentTestsis green on CultLibf7d4b639(Yggdrasil, 2026-10-02: 75 of 75 inGameCult.Geometry.Tests). -
Follow-ups outside Asura:
CultGeometryIsoSurface.cs:148has the same worse-than-exact float guard, and IsoSurface also accepts non-finite samples. The GameCult.Geometry owner should mirror Cut 2's fixes there.
-
-
Cut 2a-i: Hands landed it on CultLib
hands/cultmath-asura-noise(29e50ad..1add078, 8 commits, all building).-
184 of 184 CultMath tests pass. FXC
cs_5_0compiles a kernel using both new functions. dxc was not run: the tool is missing on Starfire. -
Soul pass 1 held that every named gradient mutant dies and that the smin gradient is exact (6.4e-10 against double central differences). It found:
edge.w's value is unpinned by any behavioural test;- the struct mirror compares by position, not by name (H2 and H3 survive);
- F2 = 0 gives NaN from about |p| ≥ 1e7 (the deleted guard was reachable);
idequals the jitter's x component;- the tolerance is 300× float noise (∇F2 × 1.01 survives);
- the 3×3×3 search misses F2 on 0.002% of points, and the test oracle shares the defect;
idcan reach 1.0.
Soul pass 2 found the proof of the search radius wrong, the 3×3×3 search inexact (the search moved to 5×5×5, pruned, exact), non-uniform jitter from hashing float bits, the HLSL mirror unpinned, and fragile tolerances. The closing batch ends at
05ca826: 193 of 193 tests pass, and 4 of 4 HLSL mutants die against the mirror test. -
Soul pass 3 on
e739ff1..05ca826confirmed the code and tests. Stryker (--since:29e50ad, full-file because HLSL and docs changed) scored 76.09% over the library, with 0 survivors insmin_grad,cellularandcellular_unit. The true survivors are the 8 equivalents from pass 2 (the arithmetic of the prune bound, the redundantcontinue, and>>>onuint). The pass found two false documentation claims from the closing batch:- The F6 proof says the bound is 2.5 at m=½; it is 2.75.
- A chi-square figure of 203.8 for float-bit hashing does not reproduce
(Soul got 7.7–30.9 against a critical value of 37.7). The cited test also
never calls
cellular(): the oracle and mirror tests pin the integer hash instead.
The doc-only fix is at
4922438. Cut 2a-i: CLOSED and landed on CultLibmainat268e0ef, a--no-ffmerge whose cultmath tree equals the branch. 193 of 193 tests pass on the merged tree. -
Detection floor, recorded rather than chased: the cellular gradient tolerance is 1.3e-3, about 4× the measured central-difference error. A per-component additive gradient error of ~1e-3, or a multiplicative one of ≤0.1%, survives.
-
Nervous-system finding: 10 of the 21
Timeoutmutants in cellular were false timeouts from the 4-CPU container. Rerun alone, each dies in 4–6 s. Do not gate on the Timeout bucket fromygg-verify.shruns; triage it by rerunning. -
Stopgap defect, for the Eureka skill and Idunn's verify campaign:
ygg-verify.shnames mirrors after the checkout directory's basename, so scratch worktrees namedwtshare one bare mirror across campaigns (~/eureka-verify/repos/wt.git, from 2026-09-23). Name mirrors by repo identity, not path. -
Brief defect, recurring (2 of 4 Hands passes on 2026-09-25): Hands started Stryker with a background shell job, then ended its turn "waiting to be woken", despite the brief's poll clause. Fix the template, not just the instance: verification jobs must never run as background shell tasks. Launch the job detached with a log, then poll that log in foreground chunks until it exits. Proposed for the Eureka skill's Hands brief; applied to Self's briefs from now on.
-
Budget scar: Hands ran to about 340k tokens against an estimate of ~150k. Split later CultMath cuts finer (e.g. one primitive family per Hands).
-
-
Cut 2a-ii-a (snoise gradient + seam fix): landed on CultLib
mainat65311c9(detail under 2a-ii below). -
Cut 2a-ii-b (Phacelle): landed on CultLib
mainat15c6a7e2, merginghands/cultmath-phacelle(7904e15e..0acc3728); per the merge message, Soul found no correctness defect and the MPL-2.0 port lives in its own MPL-headed files. CultPhasor caching support merged at0dc3b917. Nocultmath-v0.3.0tag exists; the release is 2a-ii-c.
Answered 2026-09-25 (see target, "Rulings on the second map pass"):
- Q10: independent 0.25 draws in zone-gen settings. Galaxy-driven variance is a later campaign.
- Q11: the band-limit wording is accepted and is in target invariant 8.
- Q12: A. Erosion keeps upstream's analytic derivative, checked against a committed error bound. There are no second derivatives this campaign.
Q13 is ruled (2026-10-02: a6720b62 answers it) and Geometry's tests are green
on main. Cut 3 is unblocked. Cuts 1 and 7 wait on fire-control merging to Aetheria master.
The Q10–Q12 texts at the end are kept as history; these answers supersede them.
Cut order
2a-i CultMath basics ─> 2a-ii gradient noise + Phacelle ─> cultmath 0.3.0 ─┐
2 surface nets (in flight) ──────────────────────────────────────────────┴─> 3 CultLib Unity release
─> 4a Asura skeleton ─> 4b-i field + sampling + Moon ─> 4b-ii lineae, dunes, plains ─> 4b-iii erosion (after Q12)
─> 5a mesh + bare render ─> 5b benchmark
─> 6a tile pass ─> 6b patch render + watertight ─> 6c crease snap
─> R Asura release tag
1 delete plugin (Aetheria master, after fire-control merges) ─> 7a defs ─> 7b integration ─> 7c docked view
4b-ii and 4b-iii can run beside 5a in separate worktrees. 5a needs only 4b-i's grids, and the later terms are more terms in the same field.
Re-splits from the brief, with reasons:
-
2a is split in two, 2a-i and 2a-ii, with one release at the end. After Q4, 2a carries five primitives, and they no longer fit one Hands pass. See 2a for the reasons.
- CultMath has no fBm, smooth-min, cellular noise, gradient noise or Phacelle
noise. The HLSL function list at
29e50adis catmullrom, clamp, csum, the béziers, damp, decay, degrees, distance, frac, hash, lengthsq, lerp, pcg/pcg3d/pcg4d, reflect, rotate, saturate, the smoothsteps, snoise, step and value_noise*. - Gaps are filled in the owner.
- 2a releases before Cut 3, because Cut 3's Geometry DLL is compiled against the CultMath version it ships beside (see Cut 3).
- CultMath has no fBm, smooth-min, cellular noise, gradient noise or Phacelle
noise. The HLSL function list at
-
Cut 4 is split into the skeleton (package, host project, headless Core tests) and the GPU work. The GPU work is split three ways:
- 4b-i: plumbing and the first archetype;
- 4b-ii: the remaining terms;
- 4b-iii: the erosion port with its sphere adaptation.
Each part is roughly 150–200k tokens.
-
Cut 5 is split into the build path and the benchmark. The benchmark has its own harness and its own committed artefact.
-
Cut 6 is split three ways, as the operator asked: the compute pass, then rendering with a watertightness proof at uniform N, then crease snapping.
-
R (Asura release tag) is its own cut, because a tag is a cut and gets its Soul pass before it is pushed (SKILL.md step 5).
-
Cut 7 is split into definitions (headless, mutation-reachable), then render integration, then the docked view.
-
Cut 1 runs on Aetheria master once fire-control has merged (Q1). Nothing in Cuts 2–6 depends on it.
Standing design decisions (means, not operator forks)
These apply across cuts. Self may overrule any of them. None is a product fork.
-
D1. The quad mesh stays a plain CultLib value, not a document. The surface-nets output is
CultGeometryQuadMesh, a sealed class with no[MessagePackObject]and no[CultDocument]. It is not a struct, because a struct'sdefaultwould carry null arrays. Asura persists nothing (target identity table), so the mesh has no wire contract to keep. -
D2. Face-weighted normals belong in CultLib, beside surface nets, as a pure function of the quad mesh. Four reasons:
- The function is engine-neutral: quads in, per-vertex normals out.
- The mesher owns the topology the normals are computed over.
- It runs on the same worker thread as the mesher, so there is no extra handoff.
- In CultLib it is reachable by Stryker; in Asura it would be Unity code that no mutation tool reaches.
Unity's
Mesh.RecalculateNormalsis not a substitute, because its weighting is not the rule the operator ruled. -
D3. The field is unit-radius in body-local space. Asura's mesh is a child of Aetheria's
Bodytransform, which already carrieslocalScale = BodyRadius(ZoneRenderer.cs:406at master). Q5 ruled for this on 2026-09-25. The field isf(p) = |p| − (1 + h(p/|p|)), wherehis a height over the unit sphere. Inside isf <= 0. -
D4. Patch points are stored once per shared element. A body's tile data is three regions, each keyed by the thing it belongs to:
- one refined point per base vertex;
E−1refined points per unique mesh edge;(N−1)²interior points per quad.
A mesh edge joins two surface-net vertices, so it is dual to one grid face. Its key is the vertex-index pair, lower index first. The patch expansion reads corners, edges and interior from these regions, so neighbouring quads read the same memory for their shared edge. Watertightness then holds by construction, not by floating-point determinism between two invocations. The same edge-keyed region carries the docked view's per-edge factor (Cut 7c). This is how invariant 5 is made impossible to violate rather than merely likely to hold.
Edge use is even, not always 2 (Soul, Cut 2 pass 1). Where the grid under-resolves the field, surface nets emits edges used by 4 quads, and it does so on realistic terrain, not only checkerboards. Soul measured 12 on a 24³ blob at amplitude 1.2, 93 at amplitude 3.0, and 11 on a 64³ low-frequency field. The key is still the vertex pair, so all 4 quads read the same edge memory and the surface stays watertight. Nothing may assume 2:
- no "the other quad" lookup;
- no manifold-only adjacency;
- no per-edge storage sized as quad-count × 2.
Cut 6b's watertightness proof must include an under-resolved noisy field with 4-use edges. Band limits (D9) reduce how often this happens but do not guarantee it never does.
-
D5. The atlas is per body. A body's tile storage is one
GraphicsBuffersized from its quad count and N. Its "allocation table" is the identity: slot = the quad's index in the mesher's sorted output. It is freed by disposing the buffer on unload. This satisfies the target's identity row ("quad id → slot index; freed on unload"). A shared atlas with a block allocator waits until the benchmark shows draw or buffer count is the cap. Record the decision; do not build the allocator. -
D6. Asura keeps a headless Core. Rules with no GPU in them live in a
noEngineReferencesassembly: level and N selection, the remesh/retile decision, mesh-edge derivation, the patch layout offsets and the build job body. A smallnet10.0test project compiles those sources, the wayAetheria.Sharedcompiles ServerShared. This is the only way Stryker reaches Asura. GPU rules are defended by readback checks at the layer where they fail. -
D7. Unity tests run in a host project inside the Asura repo (
unity/AsuraHost/), following CultLib's ownsrc/GameCult.Unityhost project. Aetheria's tree stays out of the loop until Cut 7. -
D8. Analytic derivatives at every level (target invariant 8). The operator, 2026-09-25: "make sure we have analytic derivatives flowing down every level."
asura_field(p, body, band) → float4(∇f.xyz, f.w).- Every term and every CultMath primitive under it returns value and gradient together, in the same layout, and composition uses the chain rule.
- The sphere composition is
∇f = u − (I − uuᵀ)∇h/|p|, withu = p/|p|. This is the highest-risk site for a quiet error: a sign, a dropped projection, or a missing1/|p|. 4b-i tests it off the unit sphere. - There are no finite differences anywhere in the field or its consumers. Sampling, Newton refinement, tile normals, crease detection, the erosion input and the dock query all read the one analytic gradient.
- One evaluation per point then serves everything. Central differences would have cost 4–7 per point.
- Finite differences exist only in tests, as the property-test oracle.
- The one term that cannot meet a finite-difference tolerance is erosion (probe: 4b-iii). Q12 puts that fork to the operator.
- Second derivatives (the erosion article's curvature feature) are out of this campaign; record them.
-
D9. Every multi-octave term is band-limited by the caller's footprint. The consumer passes its sample spacing:
- the grid cell size for sampling;
- the patch spacing (base cell ÷ N) for tiles;
- a fixed small footprint for the dock query.
Octaves above Nyquist for that spacing are dropped, and the last kept octave is faded. This applies to fBm, ridged dunes, crater scales, lineae scales and erosion octaves. Without it, the coarse grid aliases erosion gullies and dune crests into topological noise in the surface-net mesh. The full field is the footprint → 0 limit. Each lowering evaluates the same function, band-limited to what it can resolve, so tile refinement adds exactly the detail the grid could not hold. Watertightness is unaffected: D4 stores shared points once, and a body's patch spacing is uniform except in 7c, where edges carry their own factor. Target amendment for Self: Q11.
Verification hosts (applies to every cut)
- Yggdrasil (
C:\Users\Meta\.claude\skills\eureka\tools\stopgap\ygg-verify.sh, imagedotnet) runs every .NET build, test and Stryker run: CultLib Geometry, CultMath,Asura.Core.TestsandAetheria.Shared.Tests. Example:ygg-verify.sh /f/Projects/CultLib <rev> dotnet 'dotnet tool restore && cd tests/GameCult.Geometry.Tests && dotnet test && dotnet stryker --since:<base>'. Keep the job's own exit status last; see the script's header. - The Windows workstation (Starfire) runs anything Unity, anything that needs
the GPU, and the CultLib Unity package build. The package build runs
build-quic-native.ps1, which is Windows-native. Rules for Starfire:- One job at a time.
- Run detached, with a log in the scratchpad and the PID recorded, and poll the log.
- Batchmode refuses a project that is open in the editor.
- Never pass
-nographicsto a GPU test. Unity's docs for 6000.3 say that in batch mode with-nographics"Unity doesn't initialize the graphics device". - Revert incidental asset churn after every run.
- Shader compile probe (done in this pass):
CultMath.hlslat29e50ad, included into acs_5_0kernel that sums six octaves ofcultmath_snoise(float3), compiles cleanly with FXC (Windows Kits\10\bin\10.0.26100.0\x64\fxc.exe /T cs_5_0). FXC is the compiler Unity uses for D3D11 compute. Aetheria's Windows APIs are D3D11 first, then D3D12 (ProjectSettings.asset:350,m_APIs: 0200000012000000, not automatic), and the render pipeline is Built-in (GraphicsSettings.asset:41,m_CustomRenderPipeline: {fileID: 0}). No Aetheria shader includesCultMath.hlslyet, so Asura is its first Unity compute consumer. - AsyncGPUReadback: the 6000.3 script reference states no render-pipeline
requirement.
Request(GraphicsBuffer, size, offset, callback)exists, and the docs say readback "adds a few frames of latency". Platform support is exposed only throughSystemInfo.supportsAsyncGPUReadback. That is a runtime fact, so Cut 4b asserts it on Starfire's device rather than assuming it.
Cut 1. Aetheria: delete the Celestial Body plugin (subtraction only)
- Repo/branch: Aetheria,
hands/asura-cut1, frommasterafter the fire-control line has merged (Q1, 2026-09-25). Before dispatch, Self re-pins master's HEAD and re-checks the anchors below. They are given for master9b85211fand forab14552a, because post-merge master will carryab14552a's lines. The plugin's own files are identical in both. - First:
- A worktree. Never
F:\Projects\Aetheriaitself. - The worktree needs LFS objects for the files it edits, because
*.assetis LFS (.gitattributes);Settings.assetis a 130-byte pointer in git. Rungit lfs pull --includefor those paths. - The Unity compile check needs a full LFS checkout and a Library import. That is long. Self decides whether Hands reuses an existing Aetheria worktree.
- Record the negative-grep baselines below before any edit.
- A worktree. Never
- Deletes first:
-
Assets/Plugins/Celestial Body/: 310 files, 11,344 text lines (C# 3,221;.meta3,051;.mat1,639;.shader1,342;.cginc1,219;.compute755;.asset111, all LFS pointers;.txt6), plus binaries (6 tif, 4 psd, 3 png, 3 jpg, all LFS). AlsoAssets/Plugins/Celestial Body.meta(8 lines). -
Assets/Scripts/Zone Display/PlanetObject.cs:14: theCelestialBodyGenerator Generatorfield. -
ZoneRenderer.cs:40: theLODHandler LODHandlerfield. -
ZoneRenderer.cs:397-401: the body-settings pick. Keep:396(planet = Instantiate(Planet, ZoneRoot);) and:402, which is a comment. This pick is also a live nondeterminism: planet appearance is chosen withUnityEngine.Random, not the zone seed. It dies here and is not replaced. -
ZoneRenderer.cs:423:LODHandler.FindPlanets();. -
Assets/Scripts/Gameplay/GameSettings.cs:38, theBodySettingsCollectionsfield, and:84-89, the[Serializable] class BodySettingsCollection. Lines are for master. Onab14552athey are:34and:79-84, because settings Cut 0 landed there. -
Assets/Resources/Settings.asset:77-98(smudged): theBodySettingsCollections:block of 6 collections and 7 references, all into the plugin. Edit the text with Unity closed and commit through LFS. -
Assets/Prefabs/RPG/Planets/Planet.prefab:- the disabled generator component
--- !u!114 &-7153121627769399805(:221-240,m_Enabled: 0at:228); - its
- component:entry on GameObject&8859079004937162791; - the
Generator:line at:220.
The
Terrain Meshbody (high-res-sphere.fbxwithAssets/Materials/Planet.mat),Minimap IconandGravity Wellstay. - the disabled generator component
-
Assets/Scenes/ARPG.unity:- the enabled
LODHandlercomponent--- !u!114 &1009503736(:16782-16795); - its
- component:entry on GameObject&1009503727; - ZoneRenderer's
LODHandler: {fileID: 1009503736}at:16721.
- the enabled
-
Q2, ruled delete (2026-09-25):
Assets/Resources/Prefabs/Stations/PlanetOutpost.prefab(+meta) andAssets/Resources/Locations/ReconStationAlpha.{prefab,asset}(+metas). What they reference:PlanetOutpost.prefab:322-332has an enabledCelestialBodyGeneratorwhosebodyisReconStationAlpha.asset, and:225uses the plugin'sWatchful Eye.mat.ReconStationAlpha.assetis itself aCelestialBodySettingsinstance (:12, script guid;:15-16reference plugin Shape and Shading assets).ReconStationAlpha.prefab:90usesWatchful Eye.mat.
No code, prefab, scene or asset references either prefab by guid. The content catalog (
GameData/Aetheria.cc) is not plain text (noAssets/Resourcesstrings survive a byte grep), so a string search cannot clear it. Hands must decode it (see verification) before deleting. If a live consumer appears, stop: that is a fork.
-
- Keeps:
- Everything else under
Assets/Plugins(GPU Noise, LowPoly_AsteroidsPack and the rest). PlanetObject.Body/GravityWell/Icon.- The gas-giant and sun branches of
LoadPlanet(:367-393). - Nothing from the plugin is harvested (ruling F2).
- Everything else under
- Authority map: subtraction only. No owner changes. Planet appearance becomes the stand-in sphere until Cut 7b.
- Verification (Starfire, one job):
- builds: a Unity batchmode compile of the worktree (no GPU needed, so
-nographicsis allowed here) with zero compile errors. Also anAetheria.Sharedheadless build on Yggdrasil. It is unaffected, but it proves ServerShared never touched the plugin. - catalog:
EngineAssetCheck.Runin batchmode exits 0 after the delete. It arrives with fire-control (docs/addressables-cut.md:220onab14552a). If post-merge master somehow lacks it, fall back to a scratch decode ofGameData/Aetheria.ccthrough AetherDb's cache-open path. That decode must find 0 string fields containingPlanetOutpost,ReconStationAlphaorCelestial Body, and print the count of strings scanned; the probe stays scratch. - negative, verified against master today:
rg -n -i "celestial" Assets ProjectSettings Packages -g "!*.md"→ 0. It hits onlyGameSettings.cs:88andPlanetObject.cs:14outside the plugin now.rg -n -w "LODHandler|BodySettingsCollections?|CelestialBody\w*" Assets→ 0.TextureSizeinSlime.cs:27is an unrelated field; do not grep it.- The GUID sweep: build the list of
guid:values from the plugin's 174.metafiles at the base commit, thenrg -F -f <list> Assets ProjectSettingsover the smudged tree → 0. Today it hits 6 files:Planet.prefab:230,PlanetOutpost.prefab:225,322,ReconStationAlpha.prefab:90,ARPG.unity:16791,ReconStationAlpha.asset:12,15,16andSettings.asset:79-95. Run it on the smudged tree, becausegit grepcannot see into LFS*.assetfiles.
- operator: open ARPG, enter a zone, and see rocky planets render as the stand-in sphere with no missing-script warnings in the console.
- builds: a Unity batchmode compile of the worktree (no GPU needed, so
- Rules that lose a test: none. The plugin had no tests.
- Ledger estimate: −310 files, −11,344 text lines, −16 LFS binaries, −~15 C# lines, −~45 YAML lines, −2 prefabs and −1 asset (Q2).
- Hands budget: ~120k tokens.
- Doc sweep for Self:
docs/settings-globals-cut.mdfork B (:527-535), which moves the 7 body-settings assets intoResources, is superseded by F2. Mark it history in that map the day this cut lands.
Cut 2a. CultMath: the noise primitives Asura's field needs (2a-i, 2a-ii)
- Repo/branch: CultLib,
hands/cultmath-asura-noise, fromorigin/main29e50ad, in its own worktree. Do not use Cut 2'sF:\Projects\CultLib-asura-surface-nets. 2a-i landed at268e0ef; 2a-ii's parts branch frommain(see "Split after 2a-i's overrun" below). One release,cultmath-unity-v0.3.0, at the end of 2a-ii: it is additive, so a minor bump perdocs/semver-policy.md, and it gets a Soul pass before the tag. There is one release rather than two because every CultMath release forces a matchingorg.gamecult.cultlibrelease (Cut 3's coupling rule). - Common rules for every addition:
- It lives in C#
math(packages/cultmath/src/CultMath/math.cs, nearsnoiseat:594) and inshaders/CultMath.hlsl, with the same name prefixedcultmath_. - Both tracked
CultMath.hlslcopies stay the same blob (6179d45today). - Bodies stay inside the transformation list at
packages/cultmath/docs/design.md:92. - Invariant 8 (operator, 2026-09-25: "make sure we have analytic
derivatives flowing down every level"). Every new primitive returns its
value and its analytic gradient together, laid out as
float4(∇.xyz, value.w):r.xyzis directly the gradient as afloat3, andr.wis the value;- this is the same layout for every primitive;
- new primitives have only this form. A value-only twin would be a
second path with no consumer, since the value is
.w; - the existing value-only
snoise(float3)andsnoise(float2)stay, because they are public API and removing them is a semver major.
- Two primitives yield more than one value and gradient: cellular (the F1 and
the edge distances, plus the nearest cell id) and Phacelle (cos and sin, each
with a gradient). They return a small CultMath struct instead of recomputing
the neighbourhood search per output:
CultCellularandCultPhasor.- Today the mirror comparison reflects over C# counterparts with matching parameter types, and compares only scalar and vector returns.
- So 2a-i extends
design.md's transformation list (:92) andHlslSourceCompatibilityTeststo allow one struct return shape, compared field by field and bit for bit. - That extension happens once, in the owner, with its own test: a struct mirror whose field differs must fail.
- Cell selection hashes with
pcg3d, which is integer-exact on CPU and GPU (design.md, "Integer hashing"). Nothing new uses thesin-basedhash. - Names must not contain
spherical_erosionor pull inAdvancedErosionFilter.hlsl.HlslMirrorTests.cs:7-15forbids erosion kernels in CultMath, and that ruling stands: the erosion filter is Asura's (4b-ii). THIRD-PARTY-NOTICES.md(both thepackages/cultmathand the unity package copies) gains an entry for every ported primitive.
- It lives in C#
2a-i. Smooth-min, cellular, and the mirror-test struct extension
-
Adds:
smin_grad(float4 a, float4 b, float k) → float4: polynomial smooth minimum of two value-and-gradient forms. The output gradient is the blended gradient,lerp(∇b, ∇a, h)with the same blend factorhas the value. It serves the target's bevels, the pad blend, crater rims and flood plains.cellular(float3 p) → CultCellular { float4 nearest; float4 edge; float id; }:nearest = (∇F1, F1), with∇F1 = (p − c1)/F1, the unit vector from the nearest feature;edge = (∇F2 − ∇F1, F2 − F1), for lineae along cell borders;idis the nearest cell'spcg3dhash mapped to [0,1), for per-crater size, depth and presence;- the search is over the 3×3×3 neighbourhood of jittered feature points.
fBm moves to 2a-ii, because its value-and-gradient form needs
snoise_grad.Cellular noise follows Worley 1996 ("A Cellular Texture Basis Function", SIGGRAPH), implemented fresh. webgl-noise's
cellular3D.glsl(MIT) returns only F1 and F2 with no feature vectors, so it cannot serve the gradients. -
Rules that must die:
sminvalue≤ min(a,b), and it equalsmin(with that input's gradient) when|a−b| ≥ k.F1 ≤ F2. F1 is the nearest feature, not the first found: use a fixture where the nearest lies in the last-visited cell.- Invariant 8 property test: every gradient matches central differences
of its own
.wwithin tolerance, at seeded random points, excluding a band around the F1 = F2 set (cellular borders). - The ids are constant within a feature's region.
-
Hands budget: ~150k tokens.
2a-ii. Simplex noise with analytic gradient, fBm, ridged noise, Phacelle
- Adds:
snoise_grad(float3 p) → float4(∇n, n), ported from webgl-noisesrc/noise3Dgrad.glsl(Ashima Arts / McEwan, MIT, confirmed instegu/webgl-noisetoday). It is the same noise family as the existingsnoise(the same Ashima source,CultMath.hlsl:82-84), so the existing notice entry extends.fbm_grad(float3 p, int octaves, float lacunarity, float gain) → float4: the octave sum ofsnoise_grad, with each octave's gradient scaled by its frequency.ridged_grad(float3 p, int octaves, float lacunarity, float gain) → float4:Σ aᵢ(1 − |nᵢ|)with gradient−Σ aᵢ fᵢ sign(nᵢ)∇nᵢ. This is the operator's dune term ("1-abs(snoise(p)) gives nice dune contours", 2026-09-25). The crease atnᵢ = 0is real and deliberate. Ridged multifractal noise is standard (Musgrave, Texturing and Modeling, ch. 16) and is a noise primitive, not terrain shaping, so it belongs here.phacelle(float3 p, float3 side, float offset, float normalization) → CultPhasor { float4 cos; float4 sin; }, each as(∇.xyz, value.w): Johansen's Phacelle noise, generalised to 3D cells. The caller suppliesside, the stripe's wave vector (upstream builds it asperp(dir)·freq·τ). In 3D the perpendicular is not unique, so choosing it belongs to the caller: on a sphere it iscross(p̂, flow).- It visits 4×4×4 cells with jitter ±0.5 in each axis.
- The weight is
max(0, exp(−2d²) − 0.01111), as upstream. - The output is normalised as upstream.
- The gradient is exact: it includes the weight derivatives
∇w = −4v·exp(−2|v|²)and the normalisation ((I − ĉĉᵀ)∇P/|P|above the threshold,∇P/(1−normalization)below it). Upstream's convention,∇cos ≈ −sin·side, ignores the weight gradient. The probe measured it against central differences: 16.8° off at the median and 97.8° at the 95th percentile. The exact form is 0.00° at the median and 0.03° at the 95th percentile. Invariant 8 therefore rules out upstream's shortcut here. - Exact pruning: skip any cell whose per-axis lower bound
Σ max(0, |pf − g| − 0.5)² ≥ 2.25, because its weight is exactly 0 there (exp(−4.5) = 0.011109 < 0.01111). The pruned sum is bit-identical, and a test pins that. The probe measured 14.0 of 64 cells non-zero on average. - The provenance entry cites Rune Skovbo Johansen, Phacelle Noise, MPL-2.0
(Shadertoy
t3dyWl; erosion filterwXcfWn; blog, March 2026). CultMath's Unity package is MPL-2.0, so the licence is compatible. It also records that the 3D-cell generalisation and the caller-supplied side vector are new. - No 2D variant. Nothing consumes one. Add it when a consumer appears.
- Phacelle is a primitive, not erosion code. The evidence:
- The function's parameters are point, direction, frequency, phase offset and normalisation. None of them is an erosion concept.
- The upstream doc comment calls it "The Simple Phacelle Noise function
produces a stripe pattern aligned with the input vector"
(
lpmitchell/AdvancedTerrainErosion,AdvancedTerrainErosion.cs:423-449, marked MPL-2.0, © 2025 Johansen). - The article calls it general-purpose, and it has its own standalone
Shadertoy (
t3dyWl). - The erosion-specific parts are the octave loop, masks, fade target and gully slope. They are 4b-ii's and stay in Asura.
- Rules that must die:
-
snoise_grad.wequalssnoisewithin 1e-6 (report whether it is bit-equal). -
fBm with 1 octave equals
snoise_grad; gain and lacunarity act per octave. -
Invariant 8 property test: for
snoise_grad,fbm_grad,ridged_gradand bothphacellephasor components, the gradient matches central differences of.wat seeded random points. Exclusion bands:ridged_gradexcludes a band aroundnᵢ = 0, where the crease is real;phacelleexcludes a band around the normalisation threshold.
A
ridged_gradfixture straddling a zero crossing also pins that the two one-sided gradients differ in sign. -
Phacelle:
- output magnitude is 1 where the raw magnitude is ≥ 1 − normalization;
- stripes run along the flow: with
side = cross(p̂, flow)on the unit sphere,mean|∂cos/∂flow| / mean|∂cos/∂side| ≤ 0.25. The probe measured 0.212, against 0.202 for the planar upstream function; - continuity: the max step difference shrinks tenfold per tenfold step;
- pruned output is bit-equal to unpruned.
-
- Verification (both parts, Yggdrasil):
dotnet testfrompackages/cultmath/tests/CultMath.Tests.HlslSourceCompatibilityTests.EveryMirrorFunctionMatchesCSharpMathautomatically compares every new mirror with its C# twin bit for bit; a mirror without a twin failsAssert.Contains(... MirrorOnly).- Stryker
-t mtp --since:<part base>(xunit.v3). Triage every survivor. - Starfire: FXC
cs_5_0compile of a kernel calling each new function. This extends this map's FXC probe, and FXC is Unity's D3D11 compiler. - Also
dxc -T lib_6_3 -HV 2021perdesign.md.
- Split after 2a-i's overrun (Self, 2026-09-26). 2a-i was estimated at
~150k tokens and ran to ~340k, then needed three Soul passes. 2a-ii
therefore runs as three parts, each on its own branch from CultLib
main(268e0effor the first), each with its own Soul loop, merged--no-ffwhen closed:- 2a-ii-a, branch
hands/cultmath-snoise-grad:snoise_grad,fbm_gradandridged_grad, with their rules and the shared invariant-8 property harness. Budget ~180k tokens. - 2a-ii-b, branch
hands/cultmath-phacelle:phacelleandCultPhasor, with the exact gradient, exact pruning and their rules. It reuses the struct-mirror extension from 2a-i. Budget ~200k tokens. - 2a-ii-c: the
cultmath-unity-v0.3.0release (with its coupledorg.gamecult.cultlibrelease, Cut 3's rule), after a Soul pass over29e50ad..main. It is small, so Self briefs it separately. - Both
CultMath.hlslcopies stay one blob; there is no pinned hash, since 2a-i changed it.
- 2a-ii-a, branch
- 2a-ii-a status (2026-09-26):
- Hands landed
268e0ef..c9dabbe: 201 of 201 tests pass, and FXC and dxc both compile. dxc is present on Starfire, under Windows Kits 10.0.26100.0. - It ran to ~340k tokens against a ~180k estimate. It yielded twice on background jobs despite the brief, which makes 3 of 5 Hands passes.
octavesis clamped to [0,16] because the mirror test drivesintparameters across all of int32.- Stryker did not complete. Stryker's
--sinceresolves paths against the main checkout from inside a linked worktree and ignores every mutant, so run it from a plain clone. - Soul pass 1 is out. It owns the Stryker run, plus a hypothesis: the r²=0.6
kernel of the existing
snoisegives real value discontinuities at simplex boundaries, which the property test's consistency filter hides.
- Hands landed
- Soul pass on 2a-ii-a, 2026-09-29 (Opus). Do not merge yet; an operator fork is open.
- F1, CONFIRMED, high: CultMath's existing
snoise(float3)has real value discontinuities at simplex cell boundaries (math.cs:645, kernel0.6 - d²). With r²=0.6, a lattice vertex that is not one of the four summed corners can lie inside the kernel. 22% of points have one. Across 3,383 bisected boundary crossings the max value jump is 5.2e-3 and the max gradient jump 0.26, against 2.2e-6 at r²=0.5. A straddling central difference grows as 1/eps. Upstream (stegu/webgl-noise) fixed this as "an age-old bug" by moving to 0.5 / 105. The new gradients are correct within each cell and inherit the jumps, so Asura's normals and erosion would show seams at every octave. The committed harness cannot see it, because its stencils rarely straddle a seam.NoiseGradTests.cs:23misdiagnosed the spike as a "float32 fluke". - Operator fork Q-N1: RULED (a) by the operator, 2026-09-30 ("fix snoise, go with (a)").
snoisemoves to upstream's fixed kernel. It ships as a minor version bump, with the value change named in the release notes. Aetheria's generated values change when it next bumps its CultMath pin. - Status, 2026-09-30. Hands landed the fix on CultLib
hands/cultmath-snoise-grad(0a5f4bf), following upstream stegu/webgl-noise22434e04d7: seam fix21d9fe23d7(r² 0.6→0.5), scaleff3b5d34ea(105), permutationa3e6d57095(+10). The continuity probe is red on the old kernel and green on the new one. 202 of 202 CultMath tests pass. FXC and DXC compile both HLSL copies. Stryker cannot instrument thefloat3arithmetic, so the tests are pinned by hand mutations. A narrow Soul pass is running. - Merged to CultLib main, 2026-09-30 (
65311c9) after Soul found no defect.- An independent port of upstream matches the new code in 2D and 3D, and the goldens were confirmed independently.
- Value jump is 2.2e-6 over 20k crossings. The gradient's finite-difference error is 9.7e-6.
- Every hand mutant was killed. CultMath has 248 tests and Caching 272, all green on the merged tree.
- Release follow-ups for the next CultMath release, which carries this, the BLS solver and Asura:
- rebuild the Unity
CultMath.dll, which the build script enforces; - add a
CHANGELOGentry for the public value change; - pin the two HLSL copies equal, or delete one;
- optionally, a small Starfire GPU compute check of
cultmath_snoiseagainst C#, since float-decided gradient ties could differ under FMA.
- rebuild the Unity
- Aetheria obligation at its pin bump: Aetheria's shaders carry their own old noise
(
Assets/Plugins/GPU Noise/SimplexNoise{2D,3D,Grad2D,Grad3D}.cginc, used byGalaxyMap,Clouds,BackgroundandSimplexBrush). After the bump, the CPU fBm inSettings.csand the GPU fBm inGalaxyMap.shaderdisagree. The coherent fix is for those shaders to include CultMath's HLSL and delete the copies. AetheriaEve'sGalaxyMap.shaderhas the same problem.
- Follow-up ruling, 2026-09-30: 2D follows upstream too. The +10 permutation is shared with the
2D
snoise, whose kernel and scale already matched upstream, so 2D values shift once, in the same release. There is one permutation and no second permute helper. - The question as asked:
- (a) Move
snoiseto r²=0.5 (the upstream fix). This changes a public function's values, so it needs a semver decision. - (b) Fix only
snoise_grad, which breaks the spec rule that.wequalssnoise. - Consumer found by Self: Aetheria's gameplay calls CultMath
snoisedirectly (Settings.cs:73,Environment.cs,GlobalData.cs), so (a) changes Aetheria's generated values too. - Self recommends (a) with a minor version bump and the change named in the release notes. A seam in the value function is a defect for every consumer.
- (a) Move
- F2: false provenance at
math.cs:701. The 0.6/42 constants are upstream's old buggy ones, not a "second-order-artifact-reduced" variant. - F3: numbers in the test comments are unreproducible or false (floors, the "similar or tighter" claim, 20,000 vs 5,000 points). The ridged tolerance could tighten to about 0.05.
- F4: the ridged compounding test's loop has no attempt cap, so a mutant hangs instead of failing.
- Stryker, focused on the new functions: 103 mutants, 0 survivors. Two genuine hangs
(
i++toi--at:789and:816) are covered by F4's kind of fix. 201/201 tests. - Probe to commit after the ruling: march random lines until the simplex cell changes, bisect, and assert value and gradient continuity across 400 seams.
- F1, CONFIRMED, high: CultMath's existing
- Brief rules carried from 2a-i:
- Run verification detached, with a log that Hands polls in the foreground; never as a background shell task.
- Stryker
Timeoutmutants from the 4-CPU Yggdrasil container are unreliable: rerun each alone before calling it a hang. - A number stated in code comments or
design.mdmust come from a committed test or a probe the report reproduces. 2a-i shipped an unreproducible chi-square figure. - A Soul probe that is the only defence of a rule gets committed by the next Hands pass.
- Ledger estimate (2a total): +~250 C#, +~220 HLSL, +~350 test lines, and 2 notice entries.
Cut 2. CultLib: surface nets and face-weighted normals in GameCult.Geometry
- Repo/branch: CultLib,
hands/asura-surface-nets, fromorigin/main29e50ad, in its own worktree. No release in this cut; Cut 3 releases. - Do not touch:
CultGeometryIsoSurface.cs(Extract, marching tetrahedra, unwelded,:39-101),CultGeometryDocuments.cs(includingCultGeometryTriangleMesh,:373-400) andISOSURFACE-PROVENANCE.md. - Adds, in
src/GameCult.Geometry/:-
CultGeometrySurfaceNets.Extract(float[,,] samples, float isoValue = 0f, CultVec3 origin = default, float cellSize = 1f) → CultGeometryQuadMesh. This is the same parameter list and validation asCultGeometryIsoSurface.Extract(:39-54): null, fewer than 2 samples on any axis, and a non-finite or non-positive cell size all throw the same exception types. -
CultGeometryGridEdge: a readonly struct{ int X, Y, Z; byte Axis }. It is the grid edge from sample(X,Y,Z)to(X,Y,Z)+e_Axis, which is the target's(cell, axis). It isIEquatableandIComparable, ordered by(X, Y, Z, Axis). -
CultGeometryQuadMesh, a sealed class and not a document (D1), with:Positions(float[], xyz per welded vertex);Quads(uint[], 4 vertex indices per quad);QuadEdges(CultGeometryGridEdge[], one per quad, strictly ascending);QuadCountandVertexCount.
-
CultGeometryQuadNormals.FaceWeighted(CultGeometryQuadMesh) → float[](xyz per vertex, D2). Each quad contributes its diagonal cross product(d1 × d2), whose length is twice the area of a non-planar quad, so the direction and the area weight come together. Zero-area quads contribute nothing. A vertex whose sum is zero gets a zero normal, matchingExtract's degenerate rule at:175-177. -
SURFACE-NETS-PROVENANCE.md, packed likeISOSURFACE-PROVENANCE.md(GameCult.Geometry.csprojNone Include ... Pack). It cites:- S. Gibson, "Constrained Elastic Surface Nets", MICCAI 1998 (MERL TR99-24);
- M. Lysenko, "Smooth Voxel Terrain (Part 2)", 0fps.net, 2012;
- N. Max, "Weights for Computing Vertex Normals from Facet Normals", J. Graphics Tools 4(2), 1999.
It states that the implementation is fresh and copies no code or tables. The deliberate behaviour it records:
- inside is
<= iso; - the vertex is the mean of the linearly interpolated edge crossings of its cell;
- Gibson's elastic relaxation is omitted;
- one quad is emitted per crossing interior edge;
- vertices are welded.
-
tests/GameCult.Geometry.Tests/SurfaceNetsTests.csandQuadNormalsTests.cs(NUnit, likeIsoSurfaceTests.cs). -
tests/GameCult.Geometry.Tests/stryker-config.json:"project": "GameCult.Geometry.csproj";"mutate": ["**/src/GameCult.Geometry/**/*.cs"];"mutation-level": "Standard"and the same reporters and thresholds as CultMath's (break: 0);- no
"test-runner": "mtp". This project is NUnit 4 withNUnit3TestAdapterandMicrosoft.NET.Test.Sdk, so Stryker's defaultvstestrunner applies.
-
docs/mutation-testing.md, Follow-ups: moveGameCult.Geometryfrom the unscoped list to scoped (diff-scoped only). A full-project Geometry baseline is a recorded follow-up, not this cut.
-
- Contract rules. Each gets a behavioural test, and each must die under
mutation:
-
Sign convention matches Extract. A sample
== isois inside. Fixture: the same field with one sample set to exactlyisoand toiso − εproduces identical quads.iso + εmust differ, which kills<for<=. -
Naming. A quad's
QuadEdgesentry is the grid edge whose endpoints straddle iso. Fixture: a 3×3×3 grid with one inside sample at(1,1,1)gives exactly 6 quads. Their edges are(1,1,1,a)and(1,1,1)−e_afora = 0..2. Also use a non-cubic grid (e.g. 4×3×5) with an off-centre inside blob, so axis and dimension swaps change the answer. -
Orientation. Every quad's
(d1 × d2)has a positive dot product with(outside endpoint − inside endpoint)of its edge. The single-sample fixture exercises both cases: inside at the lower end (+aedges) and inside at the upper end (−aedges). -
Vertex placement. A vertex is the mean of its cell's interpolated crossings:
- a tilted plane pins interpolation against midpoints;
- a curved-field cell whose crossings sit at unequal positions pins the mean against the first crossing;
originandcellSizeare applied, reusingExtract's determinism test shape (IsoSurfaceTests.cs:67-87).
-
Welding and closure. On a closed field (a sphere with a one-sample margin):
- every undirected quad edge is used by exactly 2 quads;
VertexCount − QuadCount == 2(Euler characteristic for genus 0).
Probed in this pass with a scratch counter over sphere grids of 8, 10, 12, 16, 32 and 64 per axis:
V − Q = 2and 0 non-manifold edges at every size. Quads come out at 72, 192, 312, 672, 3,696 and 16,968. So the operator's "couple hundred polygons" rock is a 10³–12³ grid. Assert the invariants, not these counts. On an ambiguous cell (checkerboard) the edge count is even, not 2; a test states that too, so nobody "fixes" surface nets into claiming manifoldness. -
Open boundary. An edge whose 4 surrounding cells do not all exist emits no quad. Fixture: a plane crossing the whole grid yields quads only on interior edges, and the mesh is open. This is documented, not hidden.
-
Determinism and order. Two calls give equal arrays, and
QuadEdgesis strictly ascending. Asura's slot index is the quad index (D5), so order is contract. -
Face-weighted normals:
- a large quad and a sliver sharing a vertex give a normal within ε of the large quad's;
- a closed cube-like mesh gives unit normals;
- a zero-area quad contributes nothing, and a vertex with all-zero contributions gives a finite zero vector, not NaN.
A mutant that drops the area weight (normalising each face first) must fail.
-
- Authority map:
- Owner:
GameCult.Geometryowns surface-nets topology, naming, orientation and face-weighted normals. - Inputs: a sampled grid.
- Outputs:
CultGeometryQuadMesh, plus normals on request. - Derived: nothing persisted.
- Forbidden writers: Asura must not re-derive quad naming or orientation. It consumes them.
- Owner:
- Verification (Yggdrasil):
dotnet test tests/GameCult.Geometry.Tests; paste the per-file test counts.dotnet tool restore, then fromtests/GameCult.Geometry.Testsdotnet stryker --since:29e50ad. Triage every survivor by name. Thestatic readonlycaveat indocs/mutation-testing.mdapplies to themtprunner, notvstest.- negative:
rg -n "MessagePackObject|CultDocument" src/GameCult.Geometry/CultGeometrySurfaceNets.cs src/GameCult.Geometry/CultGeometryQuadNormals.cs→ 0.
- Ledger estimate: +~220 C#, +~300 test lines, +1 provenance file, +1 Stryker config.
- Hands budget: ~200k tokens.
Cut 3. CultLib → Unity: GameCult.Geometry ships in org.gamecult.cultlib
- Repo/branch: CultLib,
hands/cultlib-unity-geometry, frommainafter Cuts 2a and 2. Releasecultlib-unity-v1.1.0: adding an assembly is additive, so a minor bump. Soul runs before the tag is pushed. - Decision (default A; Self may put it to the operator): ship
GameCult.Geometry.dllinside the existingorg.gamecult.cultlibpackage, not in a neworg.gamecult.geometry. The reasons:- Geometry depends on
GameCult.Cachingand MessagePack, which that package already ships. - The release machinery exists: the semver check, the deterministic build, the version check and the template update.
- Aetheria then gains no new UPM dependency for it.
- Geometry depends on
- Not a source: tag
gamecult-geometry-unity-v0.1.0(b65619f) and branchesorigin/codex/geometry-cultmesh-unity-releaseandorigin/codex/geometry-ownership-migration. They carry an unmerged July planetary line:Planetary*cube-sphere heightfield plus erosion,unity/org.gamecult.geometryand HLSL pages. No mainline consumes it. FensalirHEAD 58107ffreferences only CultMath, and Aetheria references neither. Do not build on it, and do not delete it; see findings. - Per-file changes against
29e50ad:scripts/build-unity-package.ps1:- add a publish of
src\GameCult.Geometry\GameCult.Geometry.csproj(netstandard2.1 viasrc/Directory.Build.props), shaped like the WebSocket publish at:77-85, with the same deterministic arguments (:65-67) and-p:CultLibPackageVersion; - add its output to
$publishedByName(:131-140); - add
"GameCult.Geometry.dll"to$expectedAssemblies(:104-130). Do not addCultMath.dll:org.gamecult.cultmathships it, and the copy loop (:149-156) copies only expected names; - after
:167-171, add a check that the packagedGameCult.Geometry.dllversion equals the package version, and that its referencedCultMathassembly version equalspackages/cultmath/unity/org.gamecult.cultmath/package.json's version (0.3.0 after 2a). This is the coupling rule: Aetheria must pin the cultmath tag Geometry was compiled against; -UpdateTemplate(:187-208) already copies every expected assembly.
- add a publish of
unity/org.gamecult.cultlib/Runtime/GameCult.CultLib.asmdef: add"GameCult.Geometry.dll"toprecompiledReferences.unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Geometry.dll.metaand.pdb.meta: new files. CopyGameCult.Caching.dll.meta's import settings and give them a fresh guid.unity/org.gamecult.cultlib/package.json:1.1.0.CHANGELOG.md,[1.1.0]→ Added: GameCult.Geometry (isosurface, surface nets, quad normals, geometry documents). It requiresorg.gamecult.cultmath0.3.0.- Also note in the changelog that this release carries the 25 commits touching
unity/org.gamecult.cultlib,src/GameCult.{Mesh,Caching,Networking}sincecultlib-unity-v1.0.60(git log cultlib-unity-v1.0.60..origin/main -- …). Aetheria inherits them in Cut 7b.
- Verification:
- Starfire, one job:
scripts/build-unity-package.ps1 -UpdateTemplate, run detached, exit 0. Then a second non-incremental run leavesgit statusclean; this is the byte-reproducibility rule. - The version check must fail when fed a cultmath
package.jsonat another version. Hands demonstrates this once, on a scratch copy, and reports it. - Unity: the Asura host project (Cut 4a) is not there yet. So a throwaway
scratch Unity project pins
cultlib-unity-v1.1.0(pushed to a scratch branch first, or afile:path) andcultmath-unity-v0.3.0, and callsCultGeometrySurfaceNets.Extractfrom an EditMode test. Zero compile errors. - Tags go at most three per push.
- Starfire, one job:
- Hands budget: ~150k tokens.
Cut 4a. Asura: Unity package skeleton, host project, headless Core
- Repo/branch: Asura
main(re-pin at dispatch;5f0f6bdtoday). Hands commits code; Self commits the map. - Adds:
unity/org.gamecult.asura/ package.json name org.gamecult.asura, unity "6000.3"; no "dependencies": UPM package.json cannot declare git deps, so README states the required cultlib/cultmath tags README.md, CHANGELOG.md, LICENSE.md (MPL-2.0, Q9) Runtime/Core/GameCult.Asura.Core.asmdef noEngineReferences: true; precompiledReferences GameCult.Geometry.dll, CultMath.dll Runtime/Core/*.cs AsuraBodyDefinition { uint Seed; AsuraMaterial Material (Rock|Ice); bool Active; bool Eroded; bool Aeolian } (no radius: D3/Q5), and AsuraFieldKind { Planet, Sphere, Box, CraterRim, Plane, DuneCrest }. The analytic kinds are fixtures and a legit baseline, and they go through the same kernels as Planet (one rule, one path). Runtime/GameCult.Asura.asmdef references Core, CultMath.UnityBridge; engine refs Runtime/Shaders/ (empty until 4b) Tests/Editor/GameCult.Asura.Tests.Editor.asmdef testables via host unity/AsuraHost/ Unity 6000.3.24f1, Built-in RP, Windows APIs D3D11 then D3D12 Packages/manifest.json "org.gamecult.asura": "file:../../org.gamecult.asura", cultlib-unity-v1.1.0, cultmath-unity-v0.3.0, com.unity.test-framework; "testables": ["org.gamecult.asura"] ProjectSettings/* minimal; Library/, Temp/, Logs/, UserSettings/ git-ignored tests/Asura.Core.Tests/ Asura.Core.Tests.csproj net10.0; <Compile Include="../../unity/org.gamecult.asura/Runtime/Core/**/*.cs"/>; ProjectReference to CultLib GameCult.Geometry and CultMath via a CultLibRoot sibling property (Aetheria.Shared's pattern, Aetheria.Shared.csproj:27-29), pinned to the cultlib-unity-v1.1.0 commit stryker-config.json mutate **/unity/org.gamecult.asura/Runtime/Core/**/*.cs .gitignore Unity host dirs, bin/obj, StrykerOutput - Contract rules, headless (Core tests, Stryker):
AsuraBodyDefinitionequality and hash cover every field. Definitions that differ in any single field are unequal.- Seed-to-offset derivation (
AsuraSeed.Offsets(seed)), one domain offset per term:- It runs in C# and is uploaded, so the HLSL never re-derives it.
- It is deterministic.
- Every component is bounded in
[0, 256), so noise keeps float precision. - Distinct seeds give distinct offsets across a 10k-seed sweep.
- Distinct terms of one seed get distinct offsets, so craters and relief are not correlated.
- It uses CultMath's
pcg3d, not a local hash.
- Verification:
- Yggdrasil:
dotnet test tests/Asura.Core.Testswith a non-zero count, then Stryker. - Starfire: host batchmode EditMode run with one smoke test (the package compiles and Core types load). Log and PID in the scratchpad.
- Yggdrasil:
- Hands budget: ~150k tokens.
Cut 4b. Asura: the planet field (4b-i, 4b-ii, 4b-iii)
The field is f(p) = |p| − (1 + h(p̂)), with p̂ = p/|p| (D3). It is a radial
height: h depends on direction only, so there are no overhangs, f is linear
along every ray from the centre, and every ray crosses f = 0 exactly once, at
r = 1 + h(p̂). The analytic fixture kinds (Box, CraterRim) are the only
non-radial fields. h composes terms chosen by the body's archetype.
Archetype → terms (target, Q4; exemplars from the target):
| Term | CultMath primitive (2a) | Used by |
|---|---|---|
| macro relief | fbm_grad |
every Planet body; amplitude per archetype |
| craters (bowl, rim, ejecta falloff; several scales) | cellular_grad, smin |
rock-dead: dense, saturated (Moon, Mercury); ice-dead: dense, relaxed, i.e. shallower with softer rims (Ganymede, Callisto); every active body: sparse, small scales only |
| flood plains | smooth max of h with a flood level L, −smin(−h, −L) |
rock-active (Io, Venus): lava-filled lows |
| lineae (ridges and grooves along cell borders; 2–3 scales) | cellular_edge_grad |
ice-active (Europa, Enceladus) |
| aeolian dunes | ridged_grad (1 − abs(snoise), stacked) |
bodies with Aeolian (Mars, Titan, Venus exemplars). The lowest octave is low frequency and high amplitude: "push the terrain really far at really low frequency to influence the silhouette" (operator, 2026-09-25). A few giant crests bend the outline, with finer dune octaves on top. |
| hydraulic erosion | Johansen's filter, sphere-adapted (4b-iii), using phacelle |
bodies with Eroded (Mars, Earth-like), layered over any archetype |
Asymmetric dunes (gentle windward, steep lee) are an optional tuning note, not a
requirement. Two ways to get them: a domain warp along a prevailing-wind
direction, or skewing n before abs. If either is tried, it is a parameter of
the dune term, not a new term.
Parameters. Core/AsuraBodyParams.From(AsuraBodyDefinition) produces the
struct the GPU reads: term enables, amplitudes, frequencies, scale counts,
densities, the flood level and smin k, dune octaves, erosion settings, and the
per-term offsets from 4a. Values are drawn from the seed inside per-archetype
ranges held in one table in Core. It also derives
A_max = Σ the terms' amplitude bounds, the most h can rise above 1. This
is parameter derivation, not field evaluation, so invariant 2 holds: C# never
computes h.
Bounds and cell count are a conscious trade, owned by Core:
- Half-extent
B = 1 + A_max + 1.5·c, wherecis the cell size. - For a level of
nsamples per axis,c = 2B/(n−1), solved together withB. - Level selection works from the projected size of
c, not of the nominal radius. A dune-heavy body with a largeA_maxspends more of its cells on empty shell, so for the same screen size it gets a largernto keep the same cell size in pixels. - The ratio
(1+A_max)is logged per body so the benchmark can show the cost.
D9 band limits are computed in Core per dispatch. The footprint is uniform
within a dispatch: c for sampling, c/N for tiles. For each multi-octave term,
Core outputs the octave count and the last octave's fade weight, and the HLSL
reads them. So band selection is Stryker-reachable, and the kernels contain no
Nyquist logic of their own.
Derivatives (invariant 8). Every term returns value and analytic gradient
together, as float4(∇.xyz, value.w), and composition uses the chain rule:
- crater profile:
P(F1)with gradientP′(F1)·∇F1, with rims joined bysmin, whose output gradient is the blended gradient; - lineae: the profile of
F2 − F1times its gradient; - dunes:
ridged_grad; - flood plains: the smooth max
−smin(−h, −L), withL's gradient 0; - erosion: 4b-iii and Q12;
- the sphere composition
∇f = u − (I − uuᵀ)∇h/|p|.
There are no finite differences anywhere in the field or its consumers.
Minimum proving set: three exemplar bodies, covering every term kind:
- Moon: rock, dead. Macro relief and dense craters.
- Europa: ice, active. Lineae and sparse craters.
- Mars: rock, dead,
ErodedandAeolian. Macro relief, craters, giant dune crests on the silhouette, and erosion.
Mars proves erosion over real crater walls and dune flanks, and it proves that
terms compose. The two remaining archetypes, rock-active (flood plains with
smin) and ice-dead (relaxed craters), are rows in the parameter table over
terms the set already exercises. They cost about a table row and a test row
each, so they land in 4b-ii with the lineae. If 4b-ii runs over budget, they are
the first items deferred.
This is three bodies rather than the brief's two-plus-erosion because the aeolian ruling added a term kind, dunes, that neither craters nor lineae exercise. Putting it on the eroded body instead of adding a fourth exemplar keeps the set at three and tests term composition for free.
4b-i. Field plumbing, grid sampling, readback, macro relief and craters (Moon)
- Adds:
Runtime/Shaders/AsuraField.hlsl, the one implementation of the field (invariant 2). It includesPackages/org.gamecult.cultmath/Shaders/CultMath.hlsl.float4 asura_height(float3 u, AsuraBodyGpu body, AsuraBand band)returns(∇h.xyz, h.w). This is the 3D gradient of the terms, before projection.float4 asura_field(float3 p, AsuraBodyGpu body, AsuraBand band)returns(∇f.xyz, f.w), built fromasura_height, with∇f = u − (I − uuᵀ)∇h/|p|, whereu = p/|p|(D8). At|p| < εit returns a finite negativef, because the centre is inside.- The analytic kinds are implemented through the same entry point.
Runtime/Shaders/AsuraSample.compute: one kernel samples every pending body's grid into one flatRWStructuredBuffer<float>. A per-body table holds offset, dims, origin,c, params and band. The linear index within a body is(x*ny + y)*nz + z.Runtime/Shaders/AsuraEval.compute: evaluatesasura_fieldat a list of points and returns(f, ∇f). Tests use it for continuity and gradient checks, and 7c's dock query uses it.Runtime/AsuraSampler.cs:- Once per frame it packs pending bodies up to a sample budget, makes one
dispatch and one
AsyncGPUReadback.Request(buffer, size, offset, cb). - It keeps a ring of K sample buffers. A buffer is never re-dispatched while its readback is in flight; when all K are in flight, it skips the frame.
- In the callback, each body's slice is copied into a fresh
float[,,]with one memcpy (fixed (float* d = &grid[0,0,0])andUnsafeUtility.MemCpy).
- Once per frame it packs pending bodies up to a sample budget, makes one
dispatch and one
Core/AsuraBodyParams(the Moon rows),Core/AsuraBounds(B, c, level from projectedc) andCore/AsuraBand.
- Mechanism probed in pass 1: a C#
float[,,]'s memory is row-major.Buffer.BlockCopyof a flat(x*ny+y)*nz+zarray intofloat[2,3,4]lands[1,2,3] == 23, so the GPU layout memcpys straight into the arrayExtracttakes. - Authority map:
- Owner:
AsuraField.hlslowns shape. Core owns parameters, bounds and bands (numbers about the field, never values of it). - Inputs: definition, level and footprint.
- Outputs: grid samples, and point evaluations.
- Derived: the
float[,,]copy is a cache, dropped once meshed. - Forbidden writers:
- any C# evaluation of
horf; - anything writing the sample buffer except
AsuraSample.compute; - any kernel-side octave or Nyquist logic.
- any C# evaluation of
- Owner:
- Verification:
- Yggdrasil: Core tests and Stryker. The rules:
- params are deterministic per seed and inside the archetype ranges;
A_max ≥the sum of enabled term bounds;- bounds satisfy
B ≥ 1 + A_max + c; - level selection uses
c: two bodies at the same screen size with differentA_maxget differentn, and the largerA_maxgets the largern; - band octave count is monotone non-increasing in footprint and never exceeds the term's maximum;
- the last octave's weight is in [0,1] and is 1 when the octave is far below Nyquist.
- Starfire, host EditMode on the real GPU, one job:
- assert
SystemInfo.supportsAsyncGPUReadback; if it is false, Asura refuses loudly and keeps stand-ins; - Layout: the Plane kind (
f = a·p + bwith distinct a.x, a.y, a.z) on a 5×7×9 grid reads back equal to the closed form at every index; - Ring integrity: 4K Plane bodies with distinct coefficients over consecutive PlayMode frames; every grid matches its own closed form;
- Radial exactness: for Moon bodies,
AsuraEvalat(1 + h(d))·dgives|f| ≤ εfor 10k random directions; - Closure: for Moon × 64 seeds × 3 levels, every boundary sample is
> 0; - Gradient (invariant 8, the highest-risk site): the analytic
∇fof the full composed field, read back fromAsuraEval, matches central differences off(at h = 1e-3·c):- angle p99 < 0.5° and relative magnitude error p99 < 1e-2;
- at seeded random points at
|p|∈ {0.6, 1.0, 1.4}, excluding a band of 2h around crater rims (cellular borders); |p| ≠ 1is mandatory, because at|p| = 1a missing1/|p|factor is invisible. Moon terms produce a 3D gradient with a radial component, so a dropped(I − uuᵀ)projection fails too.- Also run the same check on the Sphere kind with a synthetic zonal term
h(u) = a·u.z, whose∇fhas a closed form. It pins the sign of the tangential term, in case both the implementation and FD are wrong the same way.
- Determinism: the same definition sampled twice gives bit-equal grids (invariant 6, same machine);
- Seed: two seeds give different grids.
- assert
- negative:
rg -n "snoise|cellular|asura_field|asura_height" unity/org.gamecult.asura -g "*.cs"→ 0. C# never evaluates the field; band and params code must not call the primitives.
- Yggdrasil: Core tests and Stryker. The rules:
- Hands budget: ~200k tokens.
4b-ii. The remaining terms: lineae, dunes, flood plains, relaxed craters (Europa, Mars minus erosion)
- Adds:
- Lineae, from
cellular_edge_gradat 2–3 scales, forming ridge pairs (Europa's double ridges) whereF2 − F1is small. - Dunes, from
ridged_gradwith a low-frequency, high-amplitude first octave. - Flood plains, as the smooth max
−smin(−h, −L). - Relaxed crater profiles for ice.
- The table rows for all four archetypes plus the
AeolianandErodedswitches (erosion's parameters exist, but its term is a no-op until 4b-iii). - The analytic
DuneCrestkind, a single ridged crest line with a known closed form, used by 6c.
- Lineae, from
- Verification (Starfire):
- closure for every archetype ×
Aeolian× 64 seeds × 3 levels; - the invariant-8 gradient check as in 4b-i for every archetype, masking a
band around dune crests (
nᵢ = 0) and lineae borders; - radial exactness per archetype;
- silhouette: a Mars body without erosion, at a 12³ grid, shows its
lowest dune octave in the mesh outline. Pin it as: the max radial extent
over the mesh vertices exceeds
1 + 0.5·A_dune; - Core:
- lineae enabled iff the body is ice and active;
- dunes enabled iff
Aeolian; - dead bodies have a higher crater density than active ones at the same seed;
- ice craters are shallower than rock craters.
- closure for every archetype ×
- Hands budget: ~150k tokens.
4b-iii. Hydraulic erosion on the sphere (Mars)
-
Adds:
-
Runtime/Shaders/AsuraErosion.hlsl: Johansen's Advanced Terrain Erosion Filter, ported with attribution under MPL-2.0 (Q9 makes Asura MPL-2.0). -
unity/org.gamecult.asura/EROSION-PROVENANCE.md, which records:- Rune Skovbo Johansen, Advanced Terrain Erosion Filter (© 2025, MPL-2.0),
Shadertoy
wXcfWn, explained atblog.runevision.com/2026/03/fast-and-gorgeous-erosion-filter.html; - the earlier work it credits, by Clay John (Shadertoy
MtGcWh, MIT) and Felix Westin / Fewes (7ljcRW, MIT); - the reference transcription consulted:
github.com/lpmitchell/AdvancedTerrainErosion, whoseErosionFilterandPhacelleNoiseare marked MPL-2.0 and derived fromwXcfWn; - that the sphere adaptation below is new. It is not in upstream, which is written for planar heightfields.
Shadertoy returns 403 to automated fetches. If Hands needs the original shader text, the operator opens it in a browser. The lpmitchell port carries the procedure line for line.
- Rune Skovbo Johansen, Advanced Terrain Erosion Filter (© 2025, MPL-2.0),
Shadertoy
-
Not a source: CultLib's July port (
gamecult_geometry_advanced_erosion_filterandspherical_erosion, tagb65619f). Asura does not build on that line (target, "Not consumers"). Itsspherical_erosionalso lerps raw phases across cells instead of blending phasors, which is not Phacelle.
-
-
Derivatives (invariant 8; see Q12). Erosion's gradient output is upstream's own analytic derivative. In each octave the slope contribution is
mask · gullyWeight · ∇cos · strength, and the gully direction, masks and fade target are treated as locally constant. The probe shows this is far from the exact derivative: 15° median and 62° at the 95th percentile against central differences. That holds even with an exact Phacelle gradient: 13° and 63°.- The gap is structural. The flow direction, the masks and the fade target
all depend on
∇h_base, so the exact first derivative of the eroded height needs∇²h, the second derivative, of every base term. Thesign()in the gully update is nonsmooth as well. - So erosion cannot meet invariant 8's finite-difference tolerance without a Hessian flow through every term. Q12 puts that fork to the operator. The map is written for recommendation A: upstream's derivative, with a measured, recorded bound, and no finite differences anywhere.
- Curvature, the article's feature that needs a second derivative, is out of this campaign. Record it.
- The gap is structural. The flow direction, the masks and the fade target
all depend on
-
The sphere adaptation. This is design; the probe below backs it.
- Erosion runs in the tangent space of the unit sphere, entirely in 3D
vectors. Its inputs:
- the base
h: every other term, band-limited; - the base tangential gradient
∇_T h = ∇h − p̂(p̂·∇h), analytic (D8); - the fade target,
clamp(h_base / (0.6·A_relief), −1, 1), as upstream.
- the base
- The gully slope is a 3D tangent vector. It is re-projected onto the tangent
plane after every octave's update (
g ← g − p̂(p̂·g)). - Stripe cells live in 3D:
phacelle(p̂·freq, side, 0.25, normalization), withside = cross(p̂, normalize(gully)) · cellScale · τ, which is tangent and perpendicular to the flow. The derivative direction isside · −freq, as upstream'sphacelle.zw · −freq. - The output is a height delta added to
h, and a slope delta added to∇_T h(D8). - Octave count and last-octave weight come from D9.
- Rejected: a cube-face domain (a gnomonic uv per face, upstream 2D
Phacelle, faces blended as in lpmitchell's
AccumulateFaceandDirectionToFaceUv). The probe below shows why.
- Erosion runs in the tangent space of the unit sphere, entirely in 3D
vectors. Its inputs:
-
Probe (this pass). A scratch net10 C# prototype of the full octave loop, with a smooth analytic base height, scale 0.15 and 5 octaves. It measured the max
|Δh|between points a stepsapart along 40 random great-circle arcs:Variant s = 1e-3 1e-4 1e-5 Verdict 3D-cell Phacelle alone 3.4e-1 3.5e-2 3.5e-3 continuous (scales with s) erosion, 3D cells 2.9e-3 3.1e-4 3.1e-5 continuous across cell boundaries, no seams erosion, cube faces, hard switch 2.5e-2 1.4e-2 1.2e-2 discontinuous: a ~0.012 jump at face edges, about the size of the erosion itself erosion, cube faces, smoothstep blend (width 0.1 and 0.3) 5.5e-3 6.5e-4 6.4e-5 continuous, but erosion RMS near the seams is 0.73–0.75 of the interior: two unrelated gully networks are cross-faded, and gullies do not connect across the seam More from the same probe:
- Stripe alignment,
mean|∂/∂flow| / mean|∂/∂side|: 0.212 on the sphere with 3D cells, against 0.202 for upstream's planar 2D function. The adaptation keeps the stripes along the flow as well as upstream does. - Phacelle's own derivative: upstream's convention,
∇cos ≈ −sin·side, ignores the weight gradient. Against central differences it is 16.8° off at the median and 97.8° at the 95th percentile. The exact gradient (weights and normalisation included) is 0.00° at the median and 0.03° at the 95th percentile. So 2a-ii ships the exact form (invariant 8), and upstream's shortcut is not reproduced. - Cost, measured on CPU as a ratio only: 3D cells take 19.5 µs per 5-octave sample, against 5.8 µs for blended cube faces, 3.4× as much. On average only 14.0 of the 64 visited cells have non-zero weight. 2a-ii's exact pruning and D9's band limits are the planned savings. GPU cost is measured in 5b.
- Stripe alignment,
-
Verification (Starfire, through
AsuraEval):- Continuity on the GPU: the probe's arc test (40 arcs;
s= 1e-3, 1e-4, 1e-5) on a Mars body. The max|Δh|must shrink at least 5× per 10× step. - Switch: with
Erodedoff,his bit-equal to 4b-ii's output. With it on,hdiffers. - Gradient accuracy, per Q12:
- Under A, report the angle distribution of the eroded
∇fagainst central differences, and commit it as a regression bound. The probe's CPU baseline is p50 ≤ 20°, p95 ≤ 70°. - The non-erosion part of an eroded body's gradient (erosion's contribution subtracted) must still pass 4b-i's tight invariant-8 check. That proves the approximation is confined to the erosion term.
- Under A, report the angle distribution of the eroded
- Closure for eroded bodies × 64 seeds:
A_maxmust include erosion's magnitude, which upstream accumulates asmagnitude. - operator: the Mars body in the host scene, orbiting camera. Gullies branch downhill off crater walls and dune flanks, and there is no visible seam or grid pattern.
- Continuity on the GPU: the probe's arc test (40 arcs;
-
Hands budget: ~180k tokens.
Cut 5a. Asura: worker-thread meshing and bare-mesh rendering
- Adds:
-
Core/AsuraBuildJob: a pure function taking(float[,,], origin, cellSize)toCultGeometrySurfaceNets.Extract, thenCultGeometryQuadNormals.FaceWeighted, then an immutable result. There is one body perTask. Inputs are owned copies; there is no shared mutable state (invariant 7). -
Runtime/AsuraSystem(MonoBehaviour), which owns the build lifecycle. Its port:Add(key, definition, Transform parent, Renderer standIn);Remove(key);SetCamera(Camera).
Build generations are keyed by
(key, level, generation). A result whose generation is stale, or whose body was removed, is discarded on the main thread. -
Mesh upload happens on the main thread:
Mesh.SetVertices(NativeArray)andSetIndices(..., MeshTopology.Quads)with face-weighted normals, under a per-frame upload budget. It renders with Unity's built-inStandardshader. No new shader in this cut. -
Core/AsuraLevels: the projected size of the cellc(4b's bounds, which depend onA_max), not of the nominal radius, to grid level, from a quantised table (for example 8, 12, 16, 24, 32, 48, 64). Downgrades have hysteresis. A level change remeshes, and the old mesh stays visible until the new one is ready. -
Stand-in switch. Asura owns it, through the
standInrenderer Aetheria passes in. For each body exactly one of the stand-in and the Asura mesh is visible at any frame after the first ready frame, and the stand-in is visible before that.
-
- Contract rules:
- In Core: level selection is monotone non-decreasing in pixel diameter and clamped. Hysteresis means a diameter oscillating ±1% around a threshold never changes level.
- In Core: builds of distinct bodies run in parallel and give results bit-equal to sequential builds.
- In Unity: stale results are discarded.
- In Unity: the stand-in exclusivity rule above.
- Verification:
- Core tests and Stryker on Yggdrasil.
- Host PlayMode on Starfire:
- each proving-set body (Moon, Europa, Mars) at 3 levels meshes and closes on the real sampled grid, not a CPU fixture: every edge is used by an even number of quads, and exactly 2 at the tested seeds;
- removal mid-build leaves no mesh and no leaked
Mesh(countMeshobjects before and after); - the stand-in/mesh exclusivity rule is checked every frame across a level change.
- Hands budget: ~200k tokens.
Cut 5b. Asura: throughput benchmark with a committed baseline
- Adds:
- A host PlayMode test with
[Category("Benchmark")], excluded from default runs. It frames through PlayMode, so async callbacks fire as in play. - For each grid size in {8, 12, 16, 32, 64} (at least 3 are required), it submits a burst of M distinct seeds and measures steady-state bodies per millisecond from first submit to last mesh upload, excluding warm-up.
- It reports stage times: sample dispatch, readback latency in frames and ms, mesh ms per body on workers, and upload.
- Mode "mesh-only (tiles pending)" is measured here; Cut 6b adds the tiled columns.
- Rows per archetype, because erosion's per-sample cost dominates: Moon,
Europa, Mars without erosion, and Mars with erosion. Each row records its
(1 + A_max)shell factor. - A field microbenchmark:
AsuraEvalsamples per millisecond for each row, so the grid-sampling cost is separable from readback and meshing. - Expectation per D8: one field evaluation per grid sample, not 4–7.
- Output:
docs/benchmarks/<date>-<host>.md, a table. Its header records GPU name and driver, CPU, Unity version, Asura, CultLib and CultMath SHAs, and "editor PlayMode, batchmode". Committed.
- A host PlayMode test with
- Verification: the run on Starfire, one job, with no other heavy job running; the committed file. A player-build baseline is a recorded follow-up.
- Hands budget: ~120k tokens.
Cut 6a. Asura: the tile compute pass at uniform N
- Adds:
-
Core/AsuraPatchLayout: from quad count, mesh edges and N, it gives the offsets of the three regions of D4:Vvertex points;E × (N−1)edge points;Q × (N−1)²interior points.
It also derives mesh edges from the quad mesh, keyed
(min vi, max vi). Edge points are parameterised from the lower vertex index to the higher, so the parameterisation belongs to the edge and not to either quad. -
Runtime/Shaders/AsuraTiles.compute, with kernels for vertex, edge and interior points. Each point:- starts from its canonical position (the base vertex; lerp of the two refined endpoints; bilinear of the four refined corners);
- is refined onto
f = 0by normal-constrained Newton: a fixed iteration count, moving along the gradient direction at the start point, with the step clamped to one base cell. Each iteration is oneasura_fieldcall, reading value and analytic gradient together (D8). There are no finite differences.- The field is a radial height, so it is linear along rays and has moderate slopes at low frequency. Newton is therefore well-conditioned everywhere except at crease discontinuities: dune crests, crater rims and lineae. There the gradient flips across the crease, and the step clamp plus 6c's snapping take over;
- stores the analytic gradient normal and an archetype material, packed RGBA8. Rock and ice differ; so do crater floor, rim and ejecta, lineae, dune sand and eroded channels, using the terms' own masks. The gullies come from erosion's ridge map, which upstream emits beside the height.
- evaluates with the tile footprint
c/N(D9), so erosion and dune octaves that the grid could not resolve appear at tile resolution. The tile pass evaluates the same field, so no extra code is needed for this.
Vertex points are refined first, so edge and interior start points read refined corners.
-
A per-body
GraphicsBufferofAsuraPatchPoint {float3 position; float3 normal; uint material}(D5). -
Core/AsuraLevelsgains N selection from projected base-cell pixels (for example N in {1, 2, 4, 8, 16}), with the same monotone, clamp and hysteresis rules. -
Core's remesh/retile decision: an N change retiles only, and a level change remeshes and then retiles.
-
- Authority map:
- Owner: the tile kernels own tile contents.
- Inputs: field, quad mesh, N.
- Outputs: the patch buffer.
- Derived: everything in it is a cache (invariant 1).
- Forbidden writers: anything else writing the patch buffer; anything reading positions back as shape authority. The dock-site query in 7c evaluates the field, not the atlas.
- Verification (Starfire, host tests reading the buffer back):
- Refinement: on the Sphere kind at N ∈ {1, 2, 4, 8}, every point satisfies
| |p| − 1 | ≤ ε. At N = 1 this also shows base vertices snapped onto the surface. - Normals: on the Sphere,
dot(normal, normalize(p)) ≥ 1 − 1e-3at every point. - Proving set: on Moon, Europa and Mars (eroded),
|f(p)| ≤ ε·cellfor every point away from creases, and no point moves more than one base cell. - Normals are the analytic gradient: the stored normal equals
normalize(∇f)fromAsuraEvalat the stored position, bit for bit. That proves the tile kernel reads the one gradient path and does not re-derive it. - Core tests and Stryker for the layout offsets and edge derivation (each unique edge appears once; key order is canonical).
- Refinement: on the Sphere kind at N ∈ {1, 2, 4, 8}, every point satisfies
- Hands budget: ~200k tokens.
Cut 6b. Asura: patch rendering and watertightness at uniform N
- Adds:
Runtime/Shaders/AsuraPatch.hlsl. It holds the one functionasura_patch_vertex(vertexId) → (point index, patch uv), which expands each quad into an N×N patch of2·N²triangles, reading corners, edges and interior from the D4 regions.AsuraPatch.shader(Built-in RP): the vertex shader calls that function and reads the patch buffer;ForwardBaselighting uses the stored normal and material. It draws withGraphics.RenderPrimitives(procedural, no mesh), one draw per body. Shadows are out of this cut.AsuraPatchExpand.compute: a verification kernel that calls the sameasura_patch_vertexand writes the expanded triangle positions into a buffer. It exists so watertightness is measured on exactly what the vertex shader emits, with no C# replica of the expansion.- Presentation: a body whose tiles are ready draws patches. Before that, the bare mesh (5a) is drawn with face-weighted normals as the stand-in. One of the two is visible at a time, per body.
- The benchmark (5b) gains columns: tiled at N ∈ {1, 2, 4, 8}, per grid size. A new committed results file is written; the old one is kept.
- Verification (Starfire):
- Watertight: for Sphere, Moon, Europa and Mars (eroded) at N ∈ {1, 2, 4, 8} × 2 levels,
read back
AsuraPatchExpand. Each undirected triangle edge, keyed by the exact bit patterns of its two endpoints, appears an even number of times, and exactly twice on the Sphere. Report per case the edge count and the number of odd edges (must be 0). - No folds: each triangle's normal has a positive dot product with the field gradient at its centroid.
- Exclusivity: the bare-mesh/patch switch never shows both and never neither, across a retile.
- operator: Mars in the host scene, orbiting camera. The giant dune crests bend the silhouette, and erosion gullies appear as the camera closes in (N rises).
- Watertight: for Sphere, Moon, Europa and Mars (eroded) at N ∈ {1, 2, 4, 8} × 2 levels,
read back
- Hands budget: ~180k tokens.
Cut 6c. Asura: crease snapping from field gradients in the tile pass
-
Adds, in the tile kernels, after refinement, for every point (vertex points included, so N = 1 gets sharp base vertices and the mesher stays pure surface nets):
- It evaluates
asura_fieldon a stencil of fixed offsets in body-local space and reads the analytic gradients (D8; no finite differences). The offsets depend only on (body, grid level): never on the quad, N or patch coordinates. - It clusters the gradients into families by an angle threshold.
- With 2 or more families, it solves a small QEF over the tangent planes (two planes give the nearest point on the crease line, three give the corner), regularised toward the refined point.
- It clamps the move to a fraction α of the patch cell (base cell ÷ N) so no triangle folds.
- It sets a "snapped" bit in the point's material word. This is the observation at the layer where the rule is decided, and it stays in production.
Prior art is cited in a comment block and in
docs/: Kobbelt et al. 2001 (Extended Marching Cubes, feature-sensitive sampling) and Ju et al. 2002 (dual contouring QEF). The implementation is fresh. - It evaluates
-
Do not add: a dual-contouring placement option in CultLib (ruling,
target.md, "Deferred paths"). -
Verification (Starfire, readback):
- Box kind at N ∈ {2, 4, 8}: every point within one base cell of a box edge lies within ε of the true edge line; points near corners lie on the corner; zero folded triangles (6b's fold check); watertight (6b's bit-edge check).
- Box at N = 1: base vertices near box corners land within ε of the corners.
- Sphere control: the snapped-bit count is 0, and the patch buffer is bit-equal to the output with snapping compiled out. The rule is "never triggers", not merely "small effect".
- CraterRim kind (a max of terms): points within one cell of the analytic rim circle lie within ε of it; zero folds; watertight.
- DuneCrest kind (4b-ii: a ridged
1 − |n|crest with a closed form), at N ∈ {1, 2, 4, 8}:- patch points within one cell of the crest line land within ε of it;
- zero folds; watertight;
- the fixture includes a low-frequency, high-amplitude crest that shows in the silhouette at a 12³ grid.
- Small-body silhouette (N = 1 is load-bearing). The operator's giant dune
crests are creases on the outline. At 10³–12³, surface nets rounds a crest
to about the grid spacing, and a rock rendered at N = 1 has no patch
interior to recover it. So snapping the base vertices is the only thing that
keeps the crest sharp on small bodies.
- Test: a Mars body at 12³ with N = 1. The max radial extent of the snapped base vertices near the crest comes within ε·c of the analytic crest height. Without snapping it falls short by about a cell, so the test also reports the unsnapped shortfall as its control.
- Eroded control: on eroded Mars, report the snapped-point count and prove zero folds. Under Q12's recommendation, erosion's gradient is upstream's approximation. False crease families in gully networks would show here as a count out of proportion to the real creases (crests, rims). Report it for Self; do not tune it away.
- The benchmark gets one more run to record snapping's cost.
-
Hands budget: ~180k tokens.
Cut R. Asura release tag
asura-unity-v0.1.0on the commit that closes 6c. Package version0.1.0and CHANGELOG. The README states the required cultlib and cultmath tags. Soul passes before the tag is pushed.
Cut 7a. Aetheria: planet definitions from zone generation (headless)
- Repo/branch: Aetheria,
hands/asura-7a, frommasterafter fire-control merges (Q1). Worktree. Re-pin anchors at dispatch. - Per-file changes (master
9b85211f):-
Assets/Scripts/ServerShared/ZoneData.cs:69-73:PlanetDatagains:[Key(9)] uint Seed;[Key(10)] BodyMaterial Material, a ServerShared enumRock | Ice;[Key(11)] bool Active;[Key(12)] bool Eroded;[Key(13)] bool Aeolian.
Aetheria owns the definition (target identity table), so the vocabulary is Aetheria's. 7b maps it field by field onto
AsuraBodyDefinitionat the ZoneRenderer seam, and ServerShared never references Asura (negative check below). The document version follows Aetheria's schema-evolution rule foraetheria.planetdata; Hands reads that rule before choosing. -
Old records (Q3, ruled 2026-09-25): a
PlanetDatawithSeed == 0fails loudly on load, and the run regenerates. There is no compatibility path. The loud failure needs its own test. -
Assets/Scripts/ServerShared/ZoneGenerator.cs:125-128(the Planet/Planetoid branch):- Seed comes from a local generator built from the zone's stable hash
and the planet's index in
planets. The zone generator already hashesgalaxyZone.Name.StableHash() ^ pcg3d(position).xat:48. - It never draws from the shared
randomstream. Drawing from it would shift every later draw at:143-196and:209+(belts, gas colours, stations), so existing galaxies would regenerate differently. - This is the same pattern as fire-control 6b's
CombatSeedplus a local generator (Zone.csonab14552a). - The archetype comes from the seed (Q3), drawn from the same local
generator after the seed. The material and activity draw is uniform over
the four archetypes.
ErodedandAeolianare independent draws, at the probabilities in Q10. The probabilities live asZoneGenerationSettingsentries, so a later content pass can tune them, or band them by mass, without code.
- Seed comes from a local generator built from the zone's stable hash
and the planet's index in
-
- Contract rules (
tests/Aetheria.Shared.Tests, Stryker via the fire-control line'sstryker-config.json):- The same galaxy zone gives the same seeds and archetypes.
- Across a 10k-zone sweep, archetype frequencies match the configured probabilities within binomial tolerance. This kills a hard-coded archetype and a draw that ignores its probability.
- Distinct planets in one zone get distinct seeds.
- Every other field of the generated
ZonePackand its bodies is unchanged against a capture taken at the base commit. Capture it at the base, not from the new code.
- Negative:
rg -n "Asura" Assets/Scripts/ServerShared→ 0.Aetheria.Sharedcompiles ServerShared headless without the Asura package, so any reference fails that build. This is invariant 7 made structural: zone gen cannot build geometry. - Hands budget: ~120k tokens.
Cut 7b. Aetheria: Asura renders rocky bodies
- Per-file changes (master anchors; fire-control anchors differ by the
:292-301EngineAssets edits only):Packages/manifest.json:54-55:org.gamecult.cultlibtocultlib-unity-v1.1.0andorg.gamecult.cultmathtocultmath-unity-v0.3.0(Cut 3's coupling rule), and add"org.gamecult.asura": "https://github.com/GameCult/Asura.git?path=/unity/org.gamecult.asura#asura-unity-v0.1.0". Unity regeneratespackages-lock.json; commit it.ZoneRenderer.cs:- a serialized
AsuraSystem Asurafield next to:40, and anAsuraSystemobject inARPG.unitywired to it; SetCamera(MainCamera)inStart(:174).
- a serialized
ZoneRenderer.cs:394-403, the rocky branch: afterInstantiate(Planet), callAsura.Add(key, AsuraBodyDefinition{Seed, Material, Active, Eroded, Aeolian}, planet.Body.transform, planet.Body). The mapping fromPlanetDatais the one seam between the two vocabularies, and it has a test covering every field.planet.Bodyis theTerrain Meshrenderer:high-res-sphere.fbxwithPlanet.mat. It is the stand-in; see the finding on the target's "flat quad".- The Asura child transform's local scale equals the stand-in mesh's
bounds.extents.x, so the swap keeps apparent size (D3).
ZoneRenderer.cs:271-276(ClearZone):Asura.Remove(key)for each rocky key beforeDestroyImmediate.:497rotatesBody. The Asura child inherits the rotation, so nothing changes here.
- Verification (Starfire, Unity closed, one job):
- batchmode compile;
- the Aetheria EditMode suite;
- a PlayMode smoke: load a zone and assert that every rocky body reaches ready within T seconds, and that exclusivity holds;
EngineAssetCheck.Runexits 0;- operator: enter zones and see rocky and icy bodies (cratered, lined, duned, eroded) varying by seed and stable across reloads. Gas giants and suns are unchanged.
- Hands budget: ~180k tokens.
Cut 7c. Docked view: dock-site query and raised N with per-edge stitching
- Q6, ruled 2026-09-25: presentation only.
- The station stands on its parent rocky planet, at a direction derived from the station's key.
- Simulation positions are unchanged.
- Stations whose parent is not a rocky
PlanetDatakeep today's dock view. - The flattened pad waits.
- Adds (Asura):
- The dock query is
AsuraEval(4b-i) plus a closed form. The field is a radial height (4b), so the surface along directionuis exactly(1 + h(u))·u. It is oneasura_heightevaluation, and its analytic gradient gives the normalnormalize(∇f). There is no marching and no bisection. The query uses a fixed small footprint (D9), so the station sits on full detail. Readback is async. This is the target's field query; it never reads the atlas. SetFocus(key, direction, radius): quads whose centres lie within the focus radius getN_hi, and the rest get the body's N.- Per-edge factor, keyed by the mesh edge (D4):
E(edge) = max(N_a, N_b). - The edge region holds
E − 1points per edge.asura_patch_vertexstitches each quad's boundary rows to its edges' factors. - The layout moves from uniform slots to per-quad offsets, computed in Core.
- The dock query is
- Adds (Aetheria), per Q6:
- The dock-site direction is derived from the station's record key, a stable hash mapped to a unit vector. It lives in ServerShared or Unity-side Aetheria code, never in Asura, because Aetheria owns it (target identity table). Test: the same key gives the same direction, and distinct keys give distinct directions.
ActionGameManager.cs:863-881(DoDock): when the parent orbit body is a rockyPlanetData, frame the close-up. The station asset is placed at the query point and aligned to the normal, andSetFocusis called. Otherwise the current dock camera path applies unchanged.Undock(:883+) clears the focus.
- Verification:
- Starfire:
- on the Sphere kind, the query returns
dir ± ε(unit radius), with normal = dir. On Moon,AsuraEvalat the returned point gives|f| ≤ ε; - watertight: 6b's bit-edge check at mixed N (focus on,
N_hi∈ {4, 8, 16} beside N ∈ {1, 2}), zero odd edges and zero folds; - Core Stryker on the per-edge factor and offsets.
- on the Sphere kind, the query returns
- operator: dock at a station orbiting a rocky planet. The station sits on the surface without floating or sinking, and there is no crack at the N boundary.
- Starfire:
- Hands budget: ~200k tokens. The flattened pad (a
smin_gradblend of a plane intoh) is deferred by Q6. If it is ever ruled in, it is its own cut.
Subtraction ledger (estimates)
| Cut | Removed | Added | Deps/targets |
|---|---|---|---|
| 1 | 310 files, 11,344 text lines, 16 LFS binaries, ~60 C#/YAML lines, 2 prefabs + 1 asset (Q2) | 0 | −1 third-party plugin |
| 2a-i/ii | 0 | ~300 C#, ~260 HLSL, ~400 test, notices entries | cultmath-unity 0.3.0 |
| 2 | 0 | ~220 C#, ~300 test, 1 md, 1 Stryker config | none |
| 3 | 0 | ~20 script, 1 asmdef line, 2 metas, 1 DLL+pdb | cultlib-unity 1.1.0 |
| 4a | 0 | package skeleton, host project, ~150 C#, 1 test csproj | +1 Unity package, +1 host project, +1 test project |
| 4b-i/ii/iii | 0 | ~450 HLSL, ~350 C#, ~450 test, 1 provenance md | none |
| 5a/5b | 0 | ~350 C#, ~250 test, 1 results doc | none |
| 6a/6b/6c | 0 | ~450 HLSL, ~250 C#, ~400 test, 2 results docs | none |
| 7a/7b/7c | ~5 lines | ~150 Aetheria C#, ~250 Asura, ~200 test | Aetheria +1 UPM dep (asura) |
This is net additive, and deliberately so: the campaign buys a capability that did not exist (Asura). Cut 1 pays about 11k lines of unused third-party plugin toward it.
Operator questions
Answered (2026-09-25; the target's "Rulings on the cut map's questions" owns the wording)
-
Q1 (Aetheria line): Cuts 1 and 7 land on
masterafter fire-control merges. This was the recommendation. Applied in Cuts 1, 7a and 7b. -
Q2 (outpost and recon assets): delete them with the plugin. Applied in Cut 1.
-
Q3 (seeds, archetype, old saves):
- Seed from a local generator that never draws from the zone stream.
- Archetype by seed for now.
- Old saves with no seed fail loudly and regenerate, with no compatibility path.
Applied in Cut 7a.
-
Q4 (the second biome): superseded by a larger ruling. Bodies are archetypes: material (rock | ice) × activity (dead | active), with hydraulic erosion switched on per body, using Johansen's erosion filter. The operator added aeolian dunes, also switched on per body, from
1 − |snoise|, with a low-frequency, high-amplitude first octave "to influence the silhouette". And analytic derivatives at every level (invariant 8). Applied in 2a, 4a, 4b, 5b, 6a, 6c and 7a. -
Q5: the field is unit-radius and the body transform scales it (D3).
-
Q6: the docked view is presentation only. The station stands on its parent planet at a direction derived from its key. The pad waits (7c).
-
Q7: invariant 6 is bitwise on one machine and within tolerance across machines.
-
Q8: no asteroid belts in this campaign.
-
Q9: Asura is MPL-2.0.
Open
-
Q10. How often do the per-body switches fire? Zone gen draws
ErodedandAeolianfrom the planet's seed (7a). What probabilities should it use, and may they apply to any archetype?- A: independent draws,
Erodedat 0.25 andAeolianat 0.25, over any archetype, as the target says ("layered over any of these"). Both areZoneGenerationSettingsentries. - B: only rock bodies may be eroded or aeolian.
- C: tie both to an atmosphere notion that Aetheria does not have yet.
Recommended: A. It is the ruling's literal reading, and it is tunable as data. C would invent a domain concept, atmospheres, that nobody has asked for. This blocks 7a's draw, not Asura.
- A: independent draws,
-
Q11. Band-limiting (D9): Self's target amendment, not an operator fork. Invariant 1 says the field is the only truth. Under D9, each lowering evaluates the same field band-limited to its footprint, so the grid does not alias gullies and crests. Proposed wording: "The field is the only truth; a lowering may band-limit it to what its sample spacing can resolve, and never otherwise alters it." Self decides whether that needs the operator.
-
Q12. Erosion and invariant 8. The exact first derivative of the eroded height depends on
∇²hof every base term:- the gully flow direction, the masks and the fade target all depend on
∇h_base; - the
sign()in the gully update is nonsmooth.
Upstream's own analytic derivative treats those as locally constant. Against central differences on the sphere (probe, 4b-iii) it is 15° off at the median and 62° at the 95th percentile. It stays at 13° and 63° with an exact Phacelle gradient, so Phacelle is not the cause.
- A: accept upstream's analytic derivative for the erosion term only.
- It still involves no finite differences.
- Its measured bound is committed as a regression guard.
- The non-erosion part of the gradient must still pass the tight check.
- Shading on eroded bodies then uses upstream's gradients, which is how the article's own renders are shaded.
- B: carry second derivatives (Hessians) through every CultMath primitive
and term, so erosion's derivative is exact by the chain rule.
- This roughly doubles 2a.
- It still leaves the
sign()nonsmooth. - It is also what the article's curvature feature would need.
- C: change the erosion so its masks and flow do not depend on slope. That changes the look upstream was chosen for.
Recommended: A, with curvature and second derivatives recorded as out of this campaign. Invariant 8's purpose, analytic gradients with no finite differences, holds. What A gives up is only that the erosion term's gradient matches the finite difference of its height. This blocks nothing until 4b-iii; 2a is sized for A.
- the gully flow direction, the masks and the fade target all depend on
Findings for Self (not assigned to a cut)
Pass 1's findings that the target has absorbed (verified at 5f0f6bd):
- the plugin's wiring (in "Not consumers");
- the July planetary line (in "Not consumers");
- the mesh-edge identity row;
- Q7's wording of invariant 6.
Still open from pass 1:
- The target's substrate paragraph still says "flat body/icon/gravity-well
quads".
Bodyishigh-res-sphere.fbxwithPlanet.mat; onlyMinimap IconandGravity Wellare quads (Planet.prefab:15,43,98,126,254,282). It still pins cultlib 1.0.60 and cultmath 0.2.4, which are the fire-control line's. Master pins 1.0.59 and 0.2.3. - Aetheria's
settings-globals-cut.mdfork B (:527-535) is superseded by F2. Sweep it the day Cut 1 lands. ZoneRenderer.cs:497spins planets per frame, not per second. This is an Aetheria follow-up.
New in pass 2 (checked against the target at 5f0f6bd):
- Target invariant 3 still names only
cultmath_snoise(float3). The field now rests on the whole 2a set:snoise_grad,fbm_grad,ridged_grad,cellular,smin_gradandphacelle. - Target invariant 7 still says "the existing flat planet icon stands in".
The stand-in is the
Bodysphere (7b). - Target invariant 8 says "every term is checked against central finite differences away from its known creases". Erosion cannot pass that without second derivatives (probe). Q12 must be ruled before 4b-iii, and the invariant's wording must follow the ruling.
- Target invariant 2 wording is too narrow now. It says "the CPU never
evaluates the planet field". Core does evaluate numbers about the field:
parameters,
A_max, bounds and band limits. The map's forbidden-writer line draws the boundary: "never values of it". The target may want that sentence. - Shadertoy is unreadable to agents.
shadertoy.comreturns 403 to automated fetches, so Johansen's original shader text (wXcfWn,t3dyWl) was not read in this pass. The procedure was read fromlpmitchell/AdvancedTerrainErosion, whose Phacelle and erosion functions are marked MPL-2.0 and derived fromwXcfWn, and from the article. If exactness against the original matters, the operator can paste it. This is recorded as missing substrate. - Unity sphere implementations exist upstream of us, and neither fits.
lpmitchell's cube-face blend is the approach the probe rejects (seams, or
cross-faded gullies). CultLib's July
spherical_erosionlerps raw phases across cells, which is not Phacelle. Neither is a source. - voidbot MCP was unreachable in pass 1. Pass 2 did not retry it; it worked
from git,
ghand probes.