Skip to content

The Transform Affordances — ADR-129

The Transform Affordances — ADR-129

One ruling from Phase 3’s tabletop entity affordances workstream (2026-08-09, owner ruling). ADR-127 decided that elevation and facing are authoritative state and what carries them. It did not decide what produces them or how a client draws them, and both gaps were live — one of them invisibly.

It is not an audit round and mints no R*-F-* number. It was found by an implementer at the seam, the same route ADR-124, ADR-125 and R28’s three came by.

The gap it closes is the shape R28’s own header describes — a decision about to be made by accident — with one addition R28 did not have: the accidental decision had already been made and had shipped. App.tsx called net.submitTransform(entityId, rawX, rawY, 0, 0), so both fields ADR-127 added were structurally unreachable. Every layer between the domain and the wire supported them, every test passed, and no value but zero could travel.


ADR-129 — The Transform Gestures Are Pointer Modes Over One Lease, and a Facing Eases Along the Shortest Arc

Status: Accepted · Date: 2026-08-09 · Depends-on 127 (the authoritative elevation and facing this produces and draws), Depends-on 050 (the one lease all three gestures take, hold and release), Depends-on 017 (the determinism contract that keeps the ease in turn units rather than radians) · interacts with 023, 053, 064

Context

ADR-127 put raw_z and rotation on DisclosedEntity and SubmitTransform, re-derived Q-056 against the widened struct, and recorded that the fog mask and the crate’s A* stay keyed on the X/Y cell. It answered what travels. Two questions it did not answer were both already being answered, badly, by code:

Nothing produced either value. The only caller of submitTransform passed literal zeroes, with a comment explaining that the gesture was a ground-plane drag and there was no rotation control yet. The comment was accurate and the situation was worse than “unfinished”: the fields were reachable in principle and unreachable in fact, and no test could have said so, because a test asserting that a transform was sent passes against zeroes. Any commit would additionally have flattened a facing or an elevation the server already held — the same defect Epic M found on the silo behind Room.MoveActor’s default parameter, one layer further out.

Nothing eased a facing. applyWorld assigned mesh.rotation.y outright, under a comment recording the snap as deliberate: a naive lerp between a facing just under a revolution and one just over zero spins the token almost all the way round for a change of a fraction of a degree, and shortest-arc handling did not exist. The comment was right about the hazard, and the cost of the workaround was a token stepping through twenty discrete facings a second at Q-001’s tick rate — visibly worse than the position beside it, which had eased since it was written.

Decision

Three drag modes over the one lease, resolved at pointer-down and fixed for the gesture. move slides across the ground plane, rotate turns about the vertical axis, elevate raises and lowers. All three take, hold and release the same ADR-050 lease by the same path; none of them is a second ownership concept. A mode that could change mid-drag would have to re-anchor when it did — the elevation gesture measures from where the press landed on screen, the move gesture from where the token was — so a mid-gesture switch is a re-anchor wearing a modifier key, and both readings are wrong for the frame it happens on.

Two affordances select the mode, and a held modifier outranks the armed tool. The tool rail arms one of the three; Shift and Alt reach the other two without leaving the one in hand. A player holding a modifier has said something more specific than the rail did, momentarily.

A turn faces the pointer. The facing is a pure function of the cursor’s ground position relative to the token, so it carries no history: releasing and re-pressing gives the same answer, and a dropped pointer event costs nothing.

An elevation is measured in screen space and snapped to whole cells. The snap is a client convention with no server rule behind it, and that is the half most easily misread: the horizontal snap mirrors WorldPosition.Cell, which the fog mask and the crate both index, so the server genuinely has a grid there. RawZ has no quantisation at all — the silo stores what arrives. What the convention buys is a legible number, which is what a tabletop player reasons about, and it is applied during the gesture so the height shown is the height that will be committed.

A facing eases along the shortest arc, in raw turn units. The signed delta is reduced into [−2^15, 2^15], and an exact half turn — two arcs of identical length — breaks toward the positive one. The tie must be decided rather than merely resolved: every viewer computes it independently from the same wire value, and two tie-breaks disagreeing renders one token turning two ways with no message behind it.

Every commit carries all four ordinates, including the ones the gesture did not touch. This is the client half of the rule Epic M established on the silo when it removed Room.MoveActor’s default facing: unchanged is a value that gets read and sent, not a field left out.

Alternatives Considered and Why Rejected

  • A Babylon RotationGizmo. The obvious answer and the one a future reader will re-propose, so it is recorded rather than merely not chosen. Three objections, and the first is structural: a gizmo brings its own pointer capture and drag behaviours alongside the scene’s existing onPointerObservable, which detaches the camera for the gesture’s duration and routes every press through one lease request — two capture systems over one canvas is two answers to “who owns this pointer”, and the loser is whichever one the lease was not asked for. Second, a gizmo draws three axes for a one-axis quantity; ADR-127 rejected a quaternion on exactly that ground, and an affordance offering rotations the wire cannot carry is worse than one that does not. Third, a gizmo is attached to a selection, and this client has no selection concept — adding one to host a gizmo is a larger decision arriving as an implementation detail.
  • A world-space elevation handle, projecting the pointer onto a vertical plane through the token. The principled-sounding alternative to a pixel constant, and it degenerates exactly where this camera spends its time: at a shallow angle a pixel of screen travel is many units of height, so the token leaps. Screen space is the same measurement at every camera angle, which is the property that matters for a handle.
  • An elevation slider in the HUD. Needs the selection concept the gizmo needed, and puts the feedback for a spatial quantity outside the space — a player watching a slider is not watching the token clear the balcony. The numeric readout it would provide is answered instead by the token visibly rising against a drop line to its own cell.
  • Free, unsnapped elevation. Rejected as the default rather than as a possibility, and the reason is recorded above precisely because the snap is not mirroring a server rule. If a map ever wants half-cell tiers this is the line that changes, and it changes without a wire or domain consequence.
  • Interpolating the facing in radians. Puts the ADR-017 boundary inside the render loop — where the next reader has no reason to look for one — and turns the wraparound modulus into a float comparison against , which is the one constant a fixed-point path is forbidden to depend on being exact.
  • Publishing the live facing and elevation to a signal, for a HUD readout during the gesture. A pointer stream runs above 20Hz on a high-refresh display, which is the cadence world-channel.ts exists to keep out of the reactive graph; a readout would put a signal write on every pointer move for a line of text. What is published is the gesture’s mode, on the two transitions onDragChange already reports.
  • Modifier keys as the only affordance. What the brief proposed and what an implementer reaches for first. A modifier is invisible: nothing on screen says Shift turns a token, and a capability reachable only by being told about it is one most players never find. P7’s neighbour — the rail already renders four tools as visibly unwired rather than hiding them — is the same argument about disclosure, and it decided this the same way.

Consequences

The renderer had to grow two meshes, or the feature would have been correct and unobservable. A token is a cylinder and a cylinder is rotationally symmetric: a token turning through a full revolution renders an identical frame the whole way round. The facing prow is not decoration — without it the value is replicated, committed, persisted and invisible. The drop line is the same argument for elevation: in a three-quarter view an elevated token’s screen position is indistinguishable from a token on the floor two cells further away, so its ground cell is ambiguous without one.

Two of the seven rail tools stop being unwired, which is the first movement on that list since it was written.

A latent fragility in the E2E fixture surfaced, and it is worth recording because nothing would have found it otherwise. ADR-023 argues that an arrival is a baseline rather than a delta, so a newly disclosed entity is now placed at its transform rather than eased in from the origin. The fixture’s readiness gate waited for the seeded token to settle at its cell and called that “the real precondition”; it was two — the world being seeded, and the renderer having drawn, because scene.pick tests against world matrices Babylon refreshes on render and easing in took ten frames. Satisfying the stated half instantly turned every pointer spec in the suite red at once. The second precondition is now named and asserted. The general form is this corpus’s own recurring shape: a guarantee held by a coincidence of timing, described by no comment and enforced by no assertion.

Negative — Alt is claimed by some Linux window managers for window dragging, so on those desktops the elevation modifier does not reach the page. This is recorded rather than fixed: it is unreproducible on the platform this was written on, and the rail is the affordance that survives it, which is a second argument for the rail existing at all.

Negative — the hop from the scene’s commit callback to net.submitTransform in App.tsx is not covered by a test, because the E2E harness intercepts one layer earlier by design, and no test drives that component’s non-harness branch. What replaces the test is structural rather than hopeful (P3): commitTransform takes five parameters with no defaults and TabletopDeps supplies all five, so writing 0, 0 there again requires deliberately ignoring two of them — whereas the defect this replaces was a literal that type-checked perfectly.

Enforcement

interpolation.test.ts pins the shortest-arc rule including the wrap point and the half-turn tie; coordinates.test.ts pins the turn unit as distinct from Q16.16’s scale and the world-direction round trip; tabletop.headless.test.ts drives the scene under NullEngine for the ease, the spawn placement, the prow’s direction through Babylon’s own world matrix, and the elevation round trip that would otherwise drift by a token half-height per commit; transform-routing.test.ts asserts the bytes carry a non-zero elevation and facing, because that is the claim zero-valued arguments would satisfy at every other layer; e2e/token-transform.spec.ts drives both affordances in a browser.

A mutation sweep of nineteen mutations across those suites killed all nineteen, after two survivors were closed as test defects — the facing’s rounding-before-reduction rule, and the elevation gesture’s anchor and bound. That sweep is the enforcement claim, rather than the suites being green.