T-1095 fixed: template heads now receive the body's skin tone (same treatment as seg_head) — _load_head passes tone.lit with a full-white fallback mask through _attach_to_bone; without a mask sidecar the tint was silently ignored, so untinted templates read grey against mid/dark tones. Verified by lookbook render (head_001 on child, tone 6 — face matches arms). Docs: the T-1088 locomotion design document committed to docs/design/ (the sandbox code's 'design §' references now resolve in-repo); character-asset- organization gains §11 Wardrobe Pipeline and sheds three stale claims (the never-built indexed-greyscale mask plan, single-mask-per-asset for clothing, the resolved multi-region open question); CHANGELOG carries the sidequest entry; D-250 amended with the shipped cutaway-modes verdict (mode 2 default, user-decided live). Tickets: T-1088, T-1089, T-1095 closed; T-1096 filed (S9 live feel-tuning, needs the user). The Fable-5 cascade exception is closed — the cascade resumes at Phase 4 (T-750). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
43 KiB
T-1088 — 3D Locomotion Sandbox: Final Design (synthesized)
Provenance: synthesized 2026-07-06 from three independent design candidates + a three-lens judge panel (T-1088 sidequest). This is the document the sandbox code's "design §" comments reference (client/scripts/sandbox/*). Governance: D-248 (interpolation), D-249 (facing authority, amended by D-252), D-250 (cutaway decoupling), D-252 (view/movement protocol split). Committed at sidequest close-out so the section references resolve in-repo.
Ticket: T-1088 (story, under Phase-5 epic T-749; sanctioned Fable-5-window cascade exception)
Base: Candidate 1 (feel-first) — unanimous #1 across all three judge lenses, zero fatal flaws — with grafts from Candidates 0 and 2 where judges flagged superior ideas, and fixes for every deduction judges recorded against Candidate 1 itself.
Ground truth: design-input.md (code-verified 2026-07-06) + spike-findings.md. The two load-bearing repo facts that sank the other candidates were independently re-verified during this synthesis pass: character_visual.gd:184-193 (mirrored E/W facing table) and input_mapper.gd:182-195 (2D-canvas-transform mouse anchor).
0. Feel thesis + scope fence
Walking feels right when three channels each have exactly one smoothing layer, ordered crisp→soft:
- Position — constant-velocity motion between server-confirmed subtile centers, speed derived per leg from
dist/interval. No easing on the character itself: easing on a repeating 0.4 s step reads as scooting. - Yaw — shortest-arc rotation under a per-stance deg/s budget with ease-out. The body is the only thing that eases.
- Camera — exponential low-pass over the character's position. Against a constant-velocity target an exponential follower converges to a constant lag offset moving at the same velocity — zero jitter while walking, gentle ease on start/stop. This is why the character must NOT also be exponentially eased (the 2D renderer's stacked 12.0-entity + 8.0-camera lerps produce the floaty feel we are replacing).
Animation is slaved to channel 1: clip playback rate is scaled so foot cadence matches actual ground speed (kills foot-slide), and gait selection keys off render velocity, never input (so Q-020 silent rejections and bump-to-turn produce a character who turns and stands, not one who walks into a wall).
Scope fence (every deferral has a named seam — grafted from Candidate 0)
| Deferred | Seam left for it |
|---|---|
| NPC / other-entity rendering | LocomotionRig.set_wire_target() single-input contract (§4.0) + injectable idle_facing_provider; attach one rig per NPC later |
| Footstep audio (Q-063) | locomotion_anim.gd emits gait_changed(state); clip phase exposed via get_animation_player() |
| Fog-of-perception 3D overlay (Q-081) | the four-state tint (§3.2) is the memory layer; a fog shader composes on top later |
| Day/night lighting (Q-086) | scene-local lights, no baked assumptions |
ual_extended.glb wiring (risk #11) |
not needed (no locomotion clip lives there); named-library + "lib/Clip" wiring plan recorded in the D-record |
| Client movement prediction | none, by decision (D-054/D-010) — this is what makes Q-020 free (§4.3) |
| Stepped player camera yaw | OUT — not canon (ticket-premise correction, design-input §3); requires a new D/Q record before anyone builds it |
| New Gauntlet room | not needed — Hub + four 2-wide exit corridors + Sprint Gauntlet 28-tile straight cover idle/walk/sprint/corner/cutaway; constraint 12 untriggered, StableId-77 append held in reserve, server untouched |
| Implant/HudGroups occlusion | one-line HudGroups.gameplay_occluded connect to a pause flag (D-170 row 13: the mechanism is CanvasItem-only, the signal is not) |
| Persisting the greybox store | in-memory; store is RefCounted with a to_dict() seam |
| Foot-lock IK, ghost silhouettes, dithered cut edges | later polish on the same rig/shader |
1. Scene + file layout
1.1 New files (with promotion status — grafted from Candidate 2, kept under sandbox/ to avoid pre-committing Phase-5 shapes)
| File | Promotion status |
|---|---|
client/scenes/locomotion_sandbox.tscn |
sandbox-only |
client/scripts/sandbox/locomotion_sandbox.gd — boot, connect, poll, pump, descriptor, first-snapshot latch, autopilot |
sandbox-only |
client/scripts/sandbox/sandbox_constants.gd — every tuning constant (§12) |
proto-production (values migrate) |
client/scripts/sandbox/sandbox_space.gd — class_name SandboxSpace static: THE conversion authority (§2) |
proto-production |
client/scripts/sandbox/greybox_world.gd — accumulating tile store + MultiMesh painter |
store contract proto-production; meshes disposable |
client/scripts/sandbox/locomotion_rig.gd — pure-core mover + facing (static funcs) + thin Node3D wrapper |
proto-production |
client/scripts/sandbox/locomotion_anim.gd — gait state machine |
proto-production |
client/scripts/sandbox/follow_camera_3d.gd — D-148 ortho rig |
proto-production |
client/scripts/sandbox/sandbox_debug_hud.gd — numeric instrumentation |
sandbox-only |
client/shaders/sandbox/greybox_tile.gdshader — floor tint + 0.5 m minor / 1.0 m major grid |
sandbox-only |
client/shaders/sandbox/greybox_wall_cutaway.gdshader — wall tint + camera-facing height cut |
technique proto-production |
client/tests/unit/test_greybox_store.gd, test_locomotion_math.gd, test_gait_table.gd, test_sandbox_space.gd |
keep |
Directory moves are cheap at the T-962 gate; the promotion column documents the seam without a rendering3d/ structure the slice doesn't need.
Modified files (all additive): input_mapper.gd (§9 provider seam), character_visual.gd (§6.3 blend param + player accessor), client/tests/visual_capture.gd + tests/visual.json (§10.2 per-scenario scene field). Untouched: main.gd, main.tscn, entity_renderer.gd, world_renderer.gd, tile_renderer.gd, fog code, SnapshotEventRouter, everything under server/ — no new Gauntlet room.
1.2 Scene tree
LocomotionSandbox (Node3D) — locomotion_sandbox.gd
├─ WorldRoot (Node3D) # rotation.y = 45° — THE D-148 map rotation, set once in .tscn
│ ├─ Greybox (Node3D) — greybox_world.gd
│ │ ├─ FloorMM (MultiMeshInstance3D) # PlaneMesh 0.5×0.5 (faces +Y natively), greybox_tile.gdshader
│ │ └─ WallMM (MultiMeshInstance3D) # BoxMesh 0.5×2.5×0.5 offset so base sits at y=0, cutaway shader
│ └─ PlayerRig (Node3D) — locomotion_rig.gd # local position = interpolated sim position (metres)
│ └─ ModelRoot (Node3D) # local rotation.y = smoothed facing yaw (D-151)
│ └─ CharacterVisual # runtime-instantiated; its own rotation.y stays 0
├─ CameraRig (Node3D) — follow_camera_3d.gd
│ └─ Camera3D # PROJECTION_ORTHOGONAL, yaw 0 always
├─ KeyLight (DirectionalLight3D) # energy 1.2, shadows on (character_creation.tscn:65-92 recipe)
├─ FillLight (DirectionalLight3D) # energy 0.4, no shadows
├─ WorldEnvironment # ambient env, same source
└─ DebugHud (CanvasLayer) — sandbox_debug_hud.gd
Sim coords stay axis-aligned inside WorldRoot; the diamond look is that single static Transform3D (D-148 letter: rotation on the tile-grid root, never the camera). Facing yaw is applied on ModelRoot in WorldRoot-local space, so octant→yaw composes with the map rotation exactly once (the compositor-api-spec §2 concern). The camera lives outside WorldRoot at yaw 0 and follows PlayerRig.global_position (already rotated).
1.3 Boot sequence
_ready(): (1) SimBridge.server_port from SR_PORT if set (pattern visual_capture.gd:79-88); connect_to_sim() guarded on DISCONNECTED. Handshake + StartupMessage are automatic. (2) Build a local CharacterVisualDescriptor from the first valid manifest.json ids per category (GameState.character_visual_descriptor is never populated — §2.1 of design-input); no hardcoded id strings. (3) CharacterVisual.new() → ModelRoot.add_child() → load_descriptor() — in-tree before load_descriptor (shader-load-in-_ready constraint). Hidden until first snapshot. (4) Install the InputMapper facing provider (§9). (5) One-line HudGroups.gameplay_occluded connect to a pause flag. (6) Parse SR_AUTOPILOT if set (§10.2).
_process(delta): (1) var snap = SimBridge.poll_snapshot(); if snap: GameState.apply_snapshot(snap) then direct calls greybox.on_snapshot() and player_rig.on_snapshot_wire(...) — no SnapshotEventRouter (its registration lives in main.gd; two consumers don't justify touching it — grafted from Candidates 0/2, resolving a judge-flagged ambiguity in Candidate 1). (2) Input pump: for entry in InputMapper.flush_queue(): SimBridge.send_input(entry) — the only pump in the codebase lives in main.gd; without this the sandbox connects but never moves. (3) First-snapshot latch: snap rig + camera + seed greybox + show character (first snapshot arrives in _process, never _ready). (4) Push the cutaway shader uniform (§8). (5) Autopilot tick if active.
1.4 Copied vs extracted vs reimplemented (D-166)
- Reimplemented from pattern (~40 lines): poll loop, pump, connect guard, SR_PORT read, first-snapshot latch,
TELEPORT_DISTANCE_THRESHOLD = 5. Extracting a shared session driver means editingmain.gd, which D-166 freezes until Phase 5 proper. Each copy carries# Pattern: main.gd:NNN — extract to shared driver in Phase 5 (T-962). - Reused as-is:
SimBridge,GameState,SnapshotHandler,Protocol(autoloads load fromproject.godotregardless of scene — precedent: standalonecharacter_creation.tscn). - Reused with one additive seam each:
InputMapper(§9),CharacterVisual(§6.3). - Reimplemented greenfield: everything 3D. Spike camera math is ported, not the file (spikes live outside
client/; the spike's 45°-iso preset is struck by D-148). - Sandbox scripts are not autoloads → full
class_nametyping is fine; the one InputMapper field added follows the autoload untyped-var rule.
2. Coordinate & scale convention (§5.5 — stated once, converted in one file)
All conversion lives in sandbox_space.gd (grafted from Candidate 2 — the strongest structural answer to risk #8's "three cheap ways to be half-a-tile off"):
THE CONVENTION: one sim
TilePositionstep = one 0.5 m subtile (D-066/D-222). All sandbox positions/speeds are metres. Sim +x (East) → local +X; sim +y (South, Y-down) → local +Z; sim z-level → Y (Gauntlet z=0 → Y=0). Tile index → world:Vector3((t.x + 0.5) * 0.5, 0, (t.y + 0.5) * 0.5). Sim facing angle θ (0=East, +π/2=South, Y-down radians,vision_cone.rs) → ModelRoot yaw:yaw = PI/2 − θ.
Verification (re-checked this pass against Godot's actual +yaw semantics, where +90° yaw turns a +Z-facing model to +X): θ=π/2 (South) → yaw 0 → +Z=South ✓; θ=0 (East) → yaw +90° → +X ✓; θ=π (West) → −90° → −X ✓; θ=−π/2 (North) → 180° → −Z ✓. Full octant table (derived, golden-tested): South 0°, Southeast 45°, East +90°, Northeast 135°, North 180°, Northwest −135°, West −90°, Southwest −45°.
Three traps explicitly closed:
CharacterVisual.set_facing()is never called. Its internal table (west=+90°, east=−90°,character_visual.gd:184-193) assumes a mirrored axis mapping (East→−X) incompatible with ours; using it makes the model face west while walking east — the fatal flaw both other candidates shipped. The rig ownsModelRoot.rotation.yexclusively; a comment at the instantiation site names the trap.- Live wire sends tile-center floats (N+0.5); TestHarness sends integers. Always
floori()the wire float to recover the tile index, then recompute the center — byte-identical behavior in both modes. Neverround(). - D-243 "voxel = 1 m" is generation-cascade vocabulary and appears nowhere in this code. The 1 m visual tile (2×2 subtiles) exists only as major grid lines in the floor shader.
Scale sanity check (grafted from Candidate 2): corridor walkable width is 2 subtiles = 1 m — the ~1.7 m-tall character should just fit; if it doesn't, a conversion is wrong.
3. Greybox tile store (risk #1 — the one genuinely new subsystem)
3.1 Data structure (greybox_world.gd; store core is a RefCounted, headless-testable)
enum Kind { FLOOR, WALL } # wire tile_kind is walkability-derived (query.rs:78-82);
# doors are entities on the wire — Door/Object collapse to FLOOR
enum Vis { FORWARD, PERIPHERAL, BOUNDARY_WALL, REMEMBERED }
var _store: Dictionary = {} # Vector3i(x,y,z) sim coords -> Kind (last observation wins)
var _instance_index: Dictionary = {} # Vector3i -> int (slot in its MultiMesh)
var _visible_now: Dictionary = {} # Vector3i -> Vis, rebuilt each consumed snapshot
Per consumed snapshot (gated on GameState.current_tick change, the world_renderer.gd:33-36 pattern; called directly by the root): rebuild _visible_now from GameState.visible_tiles (already merged from both wire spellings by snapshot_handler.gd:52-64); new tiles append to _store and get a MultiMesh slot with its Transform3D written once; tiles whose visibility state flipped get one set_instance_color() each. Diff-only — no full-buffer rebuilds (contrast the 2D TileRenderer clear-and-reset we deliberately do not copy).
Eviction: never. Gauntlet worst case 117×125 = 14,625 tiles — trivial. Never-evicting is also the correct semantic: last observation wins is the embryo of the Phase-5 "destroyed-while-unobserved renders remembered" fog-memory D-record (grafted framing from Candidate 2). Persists across the Home-key teleport — a feature. MultiMeshes preallocate instance_count = 16384, unallocated instances scaled to zero.
3.2 Render states — four-state tint (grafted from Candidate 2; surfaces the wire's visibility enum)
| State | Instance color |
|---|---|
| Visible, Forward | white (full albedo) |
| Visible, Peripheral | 85% value |
| BoundaryWall fringe | 70% value (beyond LOS edge by design, #584) |
| Remembered (in store, not visible now) | Color(0.42, 0.45, 0.52) — dim, cool, clearly "memory" |
| Never seen | nothing rendered — the void; perception is upstream, server-enforced |
Mechanics specified (fixing a Candidate-1 hand-wave): both MultiMeshes set use_colors = true; instance color arrives in the spatial shaders as the COLOR built-in and multiplies albedo. Floors use PlaneMesh 0.5×0.5 (faces +Y natively — no orientation fix needed); walls use BoxMesh 0.5×2.5×0.5 with the mesh center offset so the base sits at y=0 (explicit, fixing Candidate 2's unstated origin detail).
3.3 Mesh strategy (engages Q-079, both sides recorded)
Choice: two MultiMeshInstance3Ds. The two things this greybox uniquely needs are per-instance state (four-state tint) and per-material shader uniforms (cutaway) — GridMap has neither per-cell color nor per-cell shader state, so it would need duplicate MeshLibrary items per tint state (item churn on every LOS change). Plain per-tile MeshInstance3D (≈15k nodes) rejected outright. Q-079 annotation carries both positions (grafted from Candidate 0's counter-case per judge instruction): GridMap with a code-built MeshLibrary + diff-only set_cell_item is a legitimately simpler greybox if per-instance color is not required, and remains the natural Phase-5 candidate for static real-asset geometry; MultiMesh won here because tint + cutaway demand per-instance parameters. Data point, not resolution. D-244 satisfied: everything is real 3D geometry.
Floor read at 1 m: greybox_tile.gdshader draws world-space grid lines — minor every 0.5 m (alpha 0.15), major every 1.0 m (alpha 0.45) — so the floor reads as D-066 1 m tiles while data stays per-subtile.
4. Locomotion interpolation (§5.2 — the core feel decision)
4.0 Rig input contract (grafted from Candidate 2, reconciled with the facing split)
# locomotion_rig.gd — thin Node3D over a pure static core
func set_wire_target(pos_m: Vector3, facing_octant: String, stance: String, tick: int) -> void
var step_window_ms_provider: Callable # player adapter: func(): return InputMapper.MOVE_INTERVAL_MS[stance]
var idle_facing_provider: Callable # player adapter: InputMapper octant (§5); NPC adapter: leave invalid → wire facing always
The rig never reads GameState or InputMapper itself — the sandbox root adapts snapshot fields into set_wire_target. An NPC adapter later feeds the same call from VisibleEntity rows with no rig changes; leaving idle_facing_provider unset collapses the facing split (§5) to pure-wire — single code path by construction, answering the NPC-uniformity objection to the split. MOVE_INTERVAL_MS is read from the InputMapper autoload, never copied (grafted from Candidate 2, fixing Candidate 1's drift risk).
4.1 Model: per-leg constant velocity, dist / interval
Rejected — exponential lerp (2D LERP_SPEED 12.0): velocity is a per-step sawtooth (~90% of a 0.5 m step in ~190 ms, then near-stop until the next target at 400–800 ms cadence) — rhythmic lurch with the walk clip cycling over near-zero ground speed; foot cadence can never sync to a sawtooth. Correct for 2D sprites, wrong for a walking skeleton (this was Candidate 0's fatal feel flaw).
Rejected — D-054's fixed 100–150 ms tween: dash-then-stand stutter at Walk/Careful/Crouch (the §5.2 conflict; resolving it is governance delta #1).
Chosen — constant-velocity legs sized to the step: on a new target, cover the distance in exactly one stance interval, so held-key motion is seamless — the character arrives precisely when the next confirmed step lands.
# On snapshot with changed player tile:
# dist = _render_pos.distance_to(new_target)
# if dist > SNAP_DIST_M (2.5 m = 5 subtiles, matches 2D TELEPORT_DISTANCE_THRESHOLD): snap all channels
# else:
# interval = step_window_ms_provider.call() / 1000.0
# _leg_speed = clamp(dist / interval, base, CATCHUP_MAX_FACTOR(3.0) * base)
# Per frame:
# _render_pos = _render_pos.move_toward(target, _leg_speed * delta)
# _velocity = dir * _leg_speed (zero on arrival) # consumed by gait selection + cadence sync
One rule handles everything: cardinal 0.5/0.4 = 1.25 m/s (Walk); diagonal 0.707/0.4 = 1.77 m/s — arrives exactly on time (no √2 on the wire, D-053; a fixed speed would accumulate lag on held diagonals); multi-tile deltas (latest-wins drops, risk #2) close within ~one interval under the 3× clamp with feet speeding up to match (§6.2); teleports (Home key, cross-map) exceed 2.5 m → hard snap of position, yaw, camera, and a 0.0-blend anim reset. First-ever snapshot snaps (2D precedent entity_renderer.gd:133-140). Stance-lag on toggle (≤1 RTT) self-corrects because every leg recomputes dist/interval.
4.2 Idle hysteresis (walk↔idle flicker killer)
Snapshot arrival jitters ±1–2 ticks against the client throttle, so the rig frequently arrives a few frames early. is_moving stays true until at-target for IDLE_ENTER_DELAY_S = 0.18 (covers 2–3 ticks of jitter at every stance; a real stop reaches Idle inside the human ~0.3 s settle expectation, overlapped by the 0.20 s gait→idle blend). This names and kills the most common discrete-step artifact.
4.3 Paused ticks, blocked moves
- Paused (
game_time.tick_rate == "Paused"): identical-position snapshots keep arriving; target-chasing is idempotent; the rig settles to Idle through normal hysteresis; the Idle clip keeps breathing (living pause). - Blocked move (Q-020): the rig chases only server-confirmed positions — nothing to roll back, ever. Visible behavior is bump-to-turn: facing updates (server sets Facing even on blocked moves), position doesn't, and gait keys off render velocity so the character turns to face the wall and stands. Annotate Q-020: a strictly interpolate-only client needs zero blocked-move handling by construction (Candidate 0's crisp framing, grafted).
5. Facing authority (§5.3 — "server feet, client eyes", new governance record)
| Rig state | Yaw target source | Why |
|---|---|---|
| Moving (incl. hysteresis window) | snapshot player_facing octant |
Server sets Facing from the move delta on every accepted move (movement.rs:479-482) — it is the motion direction, arrives in the same snapshot as the position change. |
| Idle | idle_facing_provider → InputMapper.facing_octant — the same snapped octant that rides the SetFacing wire |
D-054: only the octant crosses the wire, so any mouse-responsive idle facing is client-local by construction. Using InputMapper's own octant (not the raw angle) guarantees the model never shows an octant the server wasn't told. 2D precedent: entity_renderer.gd:186-193 already draws the indicator from client-local facing. |
Idle + dialogue_active/free_camera_mode |
frozen (last target held) | InputMapper computes but does not send octants while suppressed (input_mapper.gd:71) — freeze on the same condition. |
Quantified artifact: idle, the model turns up to one RTT (~50–100 ms localhost) before the server's vision cone — imperceptible, and disagreement is bounded by the in-flight SetFacing. Moving, mouse aim is visually ignored (feet follow feet). Single-source alternatives are strictly worse: pure-snapshot idle facing = ~250–300 ms laggy 45° pops (the 2D client already cheats past this); pure-client facing breaks server-authoritative display while moving. The NPC-uniformity objection is dissolved by the provider seam (§4.0): NPCs simply don't install a provider. D-151 ("ModelRoot rotates to the true octant angle") satisfied in both states.
Turn smoothing: yaw = lerp_angle(yaw, target, 1 − exp(−TURN_SHARPNESS·δ)), TURN_SHARPNESS = 14.0/s, per-frame change clamped to per-stance deg/s budgets: Sprint 1080, Walk 720, Careful 540, Crouch 420, Idle 600. A 180° reversal at Walk completes in ~0.25 s ≈ half a step; a 45° corner resolves in ~60 ms.
Governance: new policy → claim a D record (architecture — id assigned at claim time via pql decisions claim, never presumed) and annotate Q-084 (stays open; it parks the camera-rotation variant).
6. Animation state machine
6.1 States and clips (verified imported bare names — library "", _Loop stripped, case-sensitive)
gait(stance, moving) -> StringName is a pure function; the table is the ONLY place clip strings live:
| stance | idle | moving | NATIVE_MPS (initial guess, HUD-tuned) |
|---|---|---|---|
| Sprint | Idle (2.50 s) |
Sprint (0.67 s) |
3.2 |
| Walk | Idle |
Walk (1.33 s) |
1.4 |
| Careful | Idle |
Walk_Formal (1.33 s) |
1.2 |
| Crouch | Crouch_Idle (2.93 s) |
Crouch_Fwd (2.00 s) |
0.9 |
Careful pick (§5.4): Walk_Formal — unanimous across candidates. Stance readability is a D-053 goal; a distinct upright-deliberate silhouette reads at ortho distance where slowed Walk reads as lag. Fallback if it reads "parade march": Walk at 0.6× — one table cell. Jog_Fwd is the reserve if Sprint reads too aggressive at 2.5 m/s. A gdUnit test asserts every table cell exists in a real headless CharacterVisual's get_animation_list() (grafted from Candidate 2 — guards the case-sensitive silent-miss end-to-end, not against a constant copy).
6.2 Cadence sync (kills foot-slide)
While moving: speed_scale = clamp(rig.current_speed / NATIVE_MPS[clip], 0.6, 1.8). Because rig speed is constant per leg, speed_scale is constant per leg — no within-step wobble (the whole point of rejecting exponential lerp). Catch-up bursts push it up so feet chase instead of skating. Idle states run at 1.0. The DebugHud shows live speed + speed_scale, making tuning NATIVE_MPS a five-minute on-screen job.
6.3 Transitions: AnimationPlayer.play(name, custom_blend) — no AnimationTree
Additive CharacterVisual API (CharacterVisual is the D-149 3D compositor, explicitly not the D-166-frozen 2D renderer; this ticket is its designated first external driver):
func play_animation(anim_name: String, blend_time: float = -1.0) -> void # custom_blend passthrough; -1 = current hard cut
func get_animation_player() -> AnimationPlayer # speed_scale + phase access
Blend table: Idle→gait 0.12 s, gait→Idle 0.20 s, gait↔gait 0.15 s, ↔Crouch_* 0.25 s, teleport 0.0 (hard cut — a cross-map jump must not smear). Transitions fire only on state change (edge-triggered); loops never restart mid-cycle.
- Rejected — AnimationTree:
load_descriptor()re-runs destroy/recreateAnimPlayer(character_visual.gd:761-767), invalidating external tree paths; blend spaces buy nothing for discrete tile locomotion; UAL has no start/stop/turn clips for a tree to use anyway. The anim driver'splay(clip, blend, speed)output surface is where a tree slots in later. - Rejected — manual two-player crossfade: reimplements the engine.
- Known caveat (grafted from Candidate 0): cross-clip blending with
remove_immutable_tracks=truecan pop on bones animated in only one clip; the six clips share the full 65-bone skeleton so exposure is low — if a pop shows, the fallback is a rig-local AnimationTree, finding recorded on the ticket.
Phase preservation on gait↔gait: capture phase = current_position/length before switching, play(new, blend), seek(phase * new_length, false) so legs keep their beat. Pre-flagged caveat: verify seek-during-blend doesn't cancel the crossfade in Godot 4.6; if it does, drop the seek — the 0.15 s blend masks resync acceptably (tune-by-eye, recorded on the ticket).
ual_extended.glb: deferred. No locomotion clip needs it; naive wiring collides on the duplicate "" library name. Wiring plan (register under an explicit library name, switch addressing to "lib/Clip", per the spike loader) recorded in the D-record. Q-063 seam: gait_changed(state) signal + clip-phase access.
7. Camera (D-148 rig, locked follow)
follow_camera_3d.gd, spike pivot-orbit math (camera.position = pivot + basis * Vector3(0, 0, CAM_DIST=30)), orthographic size = 9.0, near 0.1 / far 100.
- Presets (pitch from horizontal — trust D-148's preset list over its contradictory opening prose; editorial note filed, §11):
[-30 (gameplay default), -5 frontal, -80 overhead]. T-key cycles — sanctioned dev affordance (D-148/D-158), tilt lerp 3.0/s. The spike's −45 iso preset is struck by D-148 and absent. - Yaw 0, always. Stepped player yaw is OUT (not canon; a D/Q record comes first). The diamond view is entirely
WorldRoot.rotation.y = 45°. - Locked follow (D-015, no panning):
pivot = pivot.lerp(PlayerRig.global_position, 1 − exp(−6.0·δ))— deliberately the only soft layer (§0). Against constant velocity this converges to a constant trailing offsetv/k: Walk 0.21 m, Sprint 0.42 m (~4.7% of view height) — reads as speed, zero jitter.LOOKAHEAD_S = 0.0knob exposed for the tuning pass. - Snap: rig emits
teleported→ hard pivot set; same on first-snapshot latch. - Zoom: scroll lerps ortho size 6–14 — sandbox dev affordance, ungoverned and kept that way.
8. Wall cutaway — first pass (ticket scoping call: client-side render mode)
Entirely in greybox_wall_cutaway.gdshader; per-frame CPU cost = one uniform write (u_char_pos_xz = the rig's interpolated world XZ, so the cut zone glides with the character and spatial smoothstep becomes temporal smoothness). Camera ground-forward is a constant vec2(0, -1) world-space (yaw-0 camera looks −Z; correct under the 45° WorldRoot because the test runs in world space).
vec2 to_wall = v_world_xz - u_char_pos_xz;
float side_f = smoothstep(-0.25, 0.75, to_wall.y); // wall between camera and character
float rad_f = 1.0 - smoothstep(CUT_RADIUS - CUT_BAND, CUT_RADIUS, length(to_wall)); // 5.0 m, 1.5 m band
float allowed = mix(WALL_H, CUT_HEIGHT, side_f * rad_f); // 2.5 m -> 0.75 m stub
if (v_world_y > allowed) discard;
A half-disc of camera-side wall drops to a 0.75 m stub with smooth falloff — the Xenonauts read minus per-building logic the greybox doesn't have. Open box tops are acceptable greybox fidelity; ghost band/dither/capping are named polish on the same shader. Rejected: per-cell mesh swap / per-instance scale.y rewrite (Candidate 2 — pops per tile step, full-buffer churn); hard-edged discard window (Candidate 0 — works, but the smoothstep is strictly smoother at equal cost); transparency ghosting (ortho sorting headaches).
Composition with fog (the ticket's scoping call, verified consistent): cutaway cuts render height; the four-state tint (§3.2) gates knowledge — independent factors in the same shader. Remembered walls can be cut too (the player's camera doesn't care what the character currently sees). Perception stays server-enforced upstream. If this holds through implementation, mint the perception-domain D record on landing, per the ticket.
9. Input integration — the facing_angle_provider seam (mandatory; repo-verified this pass)
InputMapper._update_facing_from_mouse() (input_mapper.gd:182-195) anchors on GameState.player_position * Constants.TILE_SIZE through the 2D canvas transform — in a 3D scene that anchor is a far-off-screen point, so facing_angle is garbage and _wasd_to_world_dir() (:202-213) rotates ALL WASD movement by it: the character could be moved but not steered, with wrong SetFacing octants riding the wire. An anchor-only override (Candidate 0) is insufficient — the screen-space mouse angle still isn't a sim-space angle under the 45° WorldRoot rotation + −30° ortho foreshortening (constant ~45° skew plus up to ~19° warp). The only correct fix, one additive seam, 2D path byte-identical when unset:
# input_mapper.gd (autoload — untyped var per parse-order rule)
var facing_angle_provider = Callable() # () -> float sim-space radians (0=East, +PI/2=South), or NAN = no update
func _update_facing_from_mouse() -> void:
if facing_angle_provider.is_valid():
var a: float = facing_angle_provider.call()
if is_finite(a):
facing_angle = a
facing_octant = _angle_to_octant(a)
return
# ... existing 2D canvas-transform path, unchanged ...
The sandbox provider: unproject the mouse (project_ray_origin/normal) onto the y=0 plane, convert through WorldRoot.to_local() (undoing the 45°), take the delta from the rig's local position, return atan2(delta.z, delta.x) — which is the sim convention because local +Z = sim +y (§2). Deadzone 0.1 m (mirrors the 2D jitter guard). Everything downstream — octant snap, SetFacing-on-change, mouse-relative WASD, dialogue suppression — runs unchanged; D-054 semantics preserved exactly. D-166: InputMapper is input infrastructure, not the frozen 2D render path; the change is additive and inert for 2D.
10. Verification
10.1 Primary loop — live eyeball with numeric instrumentation
All feel judgment happens under SR_LIVE=1 (CLAUDE.md preference; TestHarness diverges in coords/pacing — risk #9):
# T1: cd server && cargo run --bin settled-reach-server -- --test-mode
# T2: SR_LIVE=1 ~/bin/godot4 --path client res://scenes/locomotion_sandbox.tscn
Server exits on client disconnect — fresh spawn per run. DebugHud: tick, tick_rate, stance, player tile, render speed (m/s), leg distance, clip + speed_scale, snapshot age, yaw target/actual, store size — cadence sync is verifiable numerically on screen, not just by eye.
10.2 Harness + deterministic input
visual_capture.gd gains an additive per-scenario "scene" field (default res://scenes/main.tscn — zero change to existing scenarios/goldens); the fog-overlay wait is get_node_or_null-guarded when absent. Deterministic capture input is SR_AUTOPILOT (grafted from Candidate 0 — highest-fidelity of the three): SR_AUTOPILOT="east:2.0,south:1.5,stance_up,east:1.0" parsed by the sandbox root into timed Input.action_press/release on the real actions, so scripted input exercises the genuine InputMapper throttle/octant/facing path — no test-only code in the rig, no SimBridge bypass masking input bugs. ~25 lines.
tests/visual.json additions: locomotion_idle_live ("live": true, new scene, static golden — character idling at Hub spawn, −30° frame, greybox floor); locomotion_gaits (autopilot walk east → turn south → stance ladder → make visual-movie contact sheet — the gait/cutaway/memory-dim review artifact; animation frames deliberately not golden-compared); locomotion_cutaway (autopilot walk south near a wall → golden of the stub). If the harness lacks env passthrough per scenario, that is a second small additive key in the same edit.
10.3 Milestones — how each is seen (grafted spine from Candidate 2)
| Milestone | Seen via |
|---|---|
| M1 Boot: character idles at Hub spawn (50,58), lit, outlined, greybox under feet | live recipe + make screenshot SCENARIO=locomotion_idle_live |
| M2 Walk: continuous cadence, no inter-step stall, correct E/W facing | movie frames — equal displacement per frame; HUD speed constant |
| M3 Stance ladder: 4 speeds, distinct silhouettes, foot-slide tuning | locomotion_gaits movie + live Sprint Gauntlet 28-tile straight |
| M4 Steer/aim: mouse-relative WASD correct in 3D, idle aim turns the model | live manual session (the §9 seam's proof) |
| M5 Store: walk a loop — remembered tiles dim behind you, void beyond | live + before/after screenshots |
| M6 Camera: −30° default, T-cycle, constant-offset follow, 45° diamond | live; screenshot per preset |
| M7 Cutaway: south walls drop to stub, character readable behind | locomotion_cutaway screenshot |
| M8 Teleport: Home mid-walk → snap, no glide, anim hard-cut | live manual |
10.4 gdUnit4 (headless — logic only, by design of the pure-core split)
CAN cover: SandboxSpace golden conversions (tile↔world, wire-float floori recovery, all 8 octant yaws incl. the E/W-critical cells); store accumulation/never-evict/four-state flips (synthetic dicts + a fixture-replay case pushing tests/fixtures/gauntlet/*.msgpack through Protocol.decode_snapshot() → GameState → store); mover math (leg-speed derivation incl. diagonal 1.77 m/s, 3× clamp, 2.5 m snap, idle hysteresis, paused idempotence, blocked-move zero-motion); yaw shortest-arc + budget clamp; gait() every cell exists in a real headless CharacterVisual's get_animation_list(); provider octant math; WallCutaway-equivalent predicate if extracted. CANNOT cover: blend quality, foot slide, cutaway pixels, ortho composition — §10.1/10.2's job. tests/run-godot --filter locomotion|sandbox; the pre-push gate runs everything — no pre-emptive local duplication (team rule).
11. Governance deltas (consolidated)
- Claim D (architecture) — 3D locomotion interpolation: per-leg constant-velocity windows keyed to the client throttle (
dist/interval, read fromInputMapper.MOVE_INTERVAL_MS; 3× catch-up clamp; 2.5 m/5-tile teleport snap; diagonal 1.41× accepted as sim truth), cadence-syncedspeed_scale, Careful=Walk_Formal,ual_extendednamed-library wiring plan, stepped camera yaw reaffirmed OUT / T-cycle as the sanctioned dev affordance. Resolves design-input §5.2; annotate D-054 ("100–150 ms Tween" scoped to the 2D renderer henceforth). Id assigned at claim time viapql decisions claim D architecture "…". - Claim D (architecture) — 3D facing presentation authority: moving = server snapshot octant; idle = client-local aim octant (the same octant sent as SetFacing), injected via
idle_facing_provider(NPCs: unset → pure wire); frozen under input suppression; includes the verified octant→yaw table (South 0°, East +90°, West −90°, North 180° —yaw = π/2 − θ) as the confirmed compositor-api-spec §2 composition (rotation composes once via WorldRoot), and the explicit "never callCharacterVisual.set_facing()from the rig" trap note. Resolves design-input §5.3. - Annotate Q-084 — facing-source settled by delta #2 for presentation; walk-vs-aim animation split and camera-rotation option remain parked.
- Annotate Q-079 — MultiMesh chosen for the sandbox (per-instance four-state tint + cutaway shader state); counter-case recorded: GridMap with code-built MeshLibrary + diff painting is simpler where per-instance color isn't needed and remains the Phase-5 static-geometry candidate. Data point, not resolution.
- Annotate Q-020 — strict no-prediction rig confirmed: silent move rejection needs zero client handling by construction; visible behavior is bump-to-turn.
- Annotate Q-063 — footstep seam exists (
gait_changedsignal + clip-phase access); animation-event vs raycast still open. - Deferred D (perception), minted at sidequest close-out per the ticket's own instruction — wall cutaway is a client-side render mode on wall data inside the information boundary, decoupled from and composing with fog-of-perception; fog-memory-applied-to-geometry nuance recorded for Phase 5.
- Editorial note on D-148 — its opening "30° tilt (60° from horizontal)" contradicts its own preset list; add a clarifying sentence (presets authoritative: −30° from horizontal). Flagged per CLAUDE.md rather than silently trusted.
- T-1088 close-out notes — no new Gauntlet room needed (constraint 12 untriggered, server untouched); Phase-5 extraction note for the ~40 lines of duplicated boot boilerplate; seek-during-blend and
remove_immutable_trackscaveats with outcomes.
12. Constants appendix (sandbox_constants.gd — every feel knob in one file)
const SUBTILE_M := 0.5 # D-066/D-222 — THE convention (§2)
const WALL_H := 2.5
const CUT_HEIGHT := 0.75
const CUT_RADIUS := 5.0
const CUT_BAND := 1.5
# stance intervals: NOT duplicated — read InputMapper.MOVE_INTERVAL_MS at runtime
const CATCHUP_MAX_FACTOR := 3.0
const SNAP_DIST_M := 2.5 # 5 subtiles — matches 2D TELEPORT_DISTANCE_THRESHOLD
const IDLE_ENTER_DELAY_S := 0.18
const TURN_SHARPNESS := 14.0
const TURN_BUDGET_DEG := {"Sprint": 1080.0, "Walk": 720.0, "Careful": 540.0, "Crouch": 420.0, "Idle": 600.0}
const GAIT_CLIP := {...} # §6.1 — the ONLY place clip strings live
const NATIVE_MPS := {"Walk": 1.4, "Walk_Formal": 1.2, "Sprint": 3.2, "Crouch_Fwd": 0.9}
const SPEED_SCALE_CLAMP := Vector2(0.6, 1.8)
const BLEND := {"idle_to_gait": 0.12, "gait_to_idle": 0.20, "gait_to_gait": 0.15, "crouch": 0.25, "teleport": 0.0}
const CAM_PITCH_PRESETS := [-30.0, -5.0, -80.0] # D-148; -45 iso struck
const CAM_DIST := 30.0
const CAM_ORTHO_SIZE := 9.0
const CAM_FOLLOW_RATE := 6.0
const CAM_TILT_RATE := 3.0
const LOOKAHEAD_S := 0.0
const MAP_ROTATION_DEG := 45.0 # WorldRoot — the single D-148 Transform3D
const TINTS := {"forward": Color.WHITE, "peripheral": 0.85, "boundary": 0.70, "remembered": Color(0.42, 0.45, 0.52)}
const MOUSE_AIM_DEADZONE_M := 0.1
13. Decisions made and why (where candidates disagreed)
- Interpolation model — exp lerp k=12 (C0) vs per-leg
dist/intervalconstant velocity (C1) vs constant-speed seek + clamp (C2): C1. Exp lerp produces a per-step velocity sawtooth foot cadence can never sync to (two judges called it fatal for feel). C1 and C2 are near-equivalent; C1's per-leg recompute plus cadence-syncedspeed_scaleis the only model under which foot-slide elimination is possible, and C2's elegant single-clamp formula is effectively subsumed. - Octant→yaw table — C0 and C2 copied
character_visual.gd'seast=−90°/west=+90°table under a sim-x→+X mapping: verified wrong against the repo this pass (the model would face west walking east, and both would have baked the mirror into governance/goldens). C1'syaw = π/2 − θ(East=+90°) is arithmetically verified and adopted;set_facing()is never called. - Mouse input in 3D — C2 missed the breakage entirely (fatal: unsteerable character); C0 fixed only the anchor (screen angle ≠ sim angle under 45°+pitch); C1's
facing_angle_provider(ray-to-ground-plane +WorldRoot.to_local+atan2(z,x)) is the only correct D-054 path and is adopted verbatim. - Facing authority — single wire source (C0/C2) vs "server feet, client eyes" split (C1): C1's split, because pure-snapshot idle aim is ~250–300 ms of laggy 45° pops the 2D client already cheats past, and the shown octant is always one the server was told. C2's NPC-uniformity objection is answered structurally: the idle source is an injected provider — NPCs leave it unset and collapse to single-source, one code path.
- Mesh strategy — GridMap (C0) vs MultiMesh (C1/C2): MultiMesh, because the four-state tint and cutaway need per-instance/per-material state GridMap lacks; C0's GridMap counter-case is preserved inside the Q-079 annotation per judge instruction.
- Cutaway — hard discard window (C0) vs smoothstep height cut in shader (C1) vs CPU classifier + instance scale.y (C2): C1 — fragment-based like C0 but with spatial falloff keyed to the interpolated position (temporal smoothness for free); C2's per-step buffer rewrite pops.
- Snapshot fan-out — SnapshotEventRouter (C1, ambiguous) vs direct consumer calls (C0/C2): direct calls — the router is wired by main.gd for the 2D stack; two consumers don't justify touching it.
- Stance intervals — copied table (C0/C1) vs read from the autoload (C2): read
InputMapper.MOVE_INTERVAL_MS— kills the drift risk. - Deterministic capture input — flows (C1) vs
SR_SCRIPTSimBridge bypass (C2) vsSR_AUTOPILOTreal Input actions (C0): C0's autopilot — it exercises the genuine InputMapper path, so captures would have caught the very input bug C2 shipped. - File organization —
sandbox/(C0/C1) vsrendering3d/promotion split (C2):sandbox/+ a promotion-status column — documents the seam without pre-committing Phase-5 directory shapes the sandbox exists to discover (judges split; the column captures C2's value at zero structural cost). C2'sSandboxSpacesingle-conversion-authority file is adopted regardless. - Wall height — 1.5 m (C0) vs 2.5 m (C1/C2): 2.5 m — walls shorter than the 1.7 m character read wrong.
- Peripheral/BoundaryWall tint — ignored (C0/C1) vs four-state (C2): four-state — one instance-color value each, and it surfaces the wire's visibility enum in the greybox for free.
- New Gauntlet room — all three: not needed. Hub + corridors + Sprint Gauntlet cover every milestone; constraint 12 untriggered.
- D-record ids — C1 presumed "next free is D-248": never presume; ids are assigned at
pql decisions claimtime.
14. Implementation plan (each step one-agent-sized; groups run in parallel)
- Group A (serial foundation): S1 scene skeleton + boot + space/constants.
- Group B (parallel on A): S2 input seam · S3 greybox store/renderer · S4 locomotion rig · S5 camera.
- Group C (parallel on B): S6 animation machine + CharacterVisual API · S7 cutaway shader.
- Group D (serial close-out): S8 harness + autopilot + scenarios → S9 live feel-tuning + governance records.
Per team rules: no pre-emptive fmt/clippy/test runs — the push gate owns verification; no server changes, so full cargo test is untouched. Feel tuning (S9) is deliberately last and human-in-the-loop against the live server.