Both static publication paths emit an authored Setup Sphere as a base-anchored Cylinder (r, 2r) while the live path emits a Sphere for the same data — different narrow-phase dispatch and a route-dependent collision difference for the same object. The fix mirrors FromSetup's step-3 emission at both sites; the contract's first test is the route-independence property asserted shape-for-shape, and the dispatch test picks geometry where cap-hit and curve-hit verdicts differ so the sphere verdict is observable, not inferred. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
3.3 KiB
S2 contract (Campaign S) — AP-155: static publication must emit authored Spheres as Spheres
Date: 2026-08-07 (overnight). Scoped by: the session lead. Implementer: one Sonnet agent. Review: dual Opus.
The divergence (AP-155, narrowed to its surviving half)
Both static publication paths emit an authored Setup Sphere as a
height-capped Cylinder:
src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs(~1018–1036)src/AcDream.Content/LandblockPhysicsContentBuilder.cs(~672–690)
Identical code in both: ShadowShape.Cylinder(radius = r*scale, cylHeight = 2r, base = origin*scale - r*ẑ rotated). The LIVE path
(ShadowShapeBuilder.FromSetup, step 3) emits ShadowShape.Sphere for the
same authored data. Consequences: (a) narrow-phase dispatch differs
(CCylSphere tests vs CSphere tests — a mover meets a flat cap where retail
meets a curved surface), and (b) the SAME object collides differently
depending on whether it arrived as a landblock static or a live spawn —
the route-dependence Campaign S's plan calls out. Retail's
CPhysicsObj::FindObjCollisions dispatches Spheres to
CSphere::intersects_sphere — there is no cylinder substitution anywhere
in retail for this branch.
The fix
Replace both emissions with ShadowShape.Sphere, mirroring
FromSetup step 3's emission EXACTLY — same origin/scale composition, same
guards (Radius <= 0 skip stays). Read FromSetup first and copy its
semantics rather than inventing; if FromSetup's sphere emission differs from
what a plain mirror would produce here (e.g. BoundsCenter handling), STOP
and report the difference instead of choosing.
Then check the ripple: BuildFloodSpheres' sphere branch (uncapped) now
sees these as Spheres — flood behaviour matches the live path, which is the
point. RetailSphereCap applies to the Cylinder branch only (AP-156's
retirement of the 10-cap on non-cyl branches) — verify with a test, not by
reading alone.
Tests
- Parity: for a representative Setup with authored Spheres, the static
publication's registered shapes are shape-for-shape identical (type,
local position, radius, scale) to
FromSetup's output for the same inputs. This is the route-independence property, asserted directly. - Dispatch: a mover resolve against a statically-published sphere object takes the Sphere narrow phase (assert via outcome on a diagonal approach that distinguishes cap-hit from curve-hit: pick geometry where cylinder and sphere verdicts differ, assert the sphere verdict).
- Sabotage: restore the Cylinder emission at ONE site; tests 1 and 2 both redden; restore.
- Existing static publication and content-builder suites stay green.
Measurement (report, not gate)
Count, over installed landblocks already swept by existing content tests (reuse their enumeration), how many static entities carry sphere-only Setups — the affected population. Three example object ids.
Scope — OUT
Register/ISSUES edits (session lead). ShadowShapeBuilder,
ShadowObjectRegistry internals. The live path. Anything about
SortingSphere (AP-157 is measured and deferred).
Acceptance
Full suite dotnet test AcDream.slnx -c Release -m:1 green, totals
reported; sabotage reported verbatim; nothing committed. Only
C:\Users\erikn\source\repos\acdream, absolute paths, no subagents;
contradictions → STOP and report.