Traversal layer v1: walk the fog between maps #11

Open
muse-lantern wants to merge 4 commits from muse-lantern/da-game:traversal-v1 into main
Member

Stacked on PR #10 (← #9 ← #8); the diff to review is the single traversal commit.

The layered architecture's missing stratum lands: World / Traversal / Player / Avatar as real scene-tree structure. The traversal region lives in its own slot under the persistent Game root — sibling to the world, owned by neither level.

How a crossing works

  1. Connections are data: a level descriptor's connections list names the levels reachable on foot (LevelRegistry.connections). Sandbox connects to itself so the whole machine is testable end-to-end with the one real level.
  2. In GAME mode, Game places a path asset (worn strip + gate posts + trigger) at the level's traversal anchor — sandbox gains one at its south edge. Walk the path, cross the trigger, the crossing starts.
  3. The region loads: one corridor scene for every crossing — dense fog (its own Environment), a walled walkway textured with the 5m triplanar grid (the supplied test texture, put to work), monolith silhouettes to judge movement by. The origin world unloads behind you; the destination streams in threaded while you walk — the walk is the loading screen, and the exit gate stays dark until the destination is ready, then lights.
  4. Reaching the open gate adopts the destination (LevelLoader.adopt), frees the region whole, and places you at the new level's traversal anchor.

The rulings, implemented as structure, not checks

  • No saving in the region: save_blocked for the whole crossing — pause Save writes nothing (asserted), Load is gated too.
  • Deletion on exit: the region is freed whole; when the item system lands, anything whose held-by chain roots in the region goes with it. Carried gear roots at the player and survives. (Nothing to drop yet — the law is in the lifetime, ready for items.)
  • NPCs have no way in — there's no NPC system yet, but the region is not part of any level they could path into; the fog-as-refuge property is structural when they arrive.
  • Return to Menu mid-crossing aborts back to the origin level, and pausing mid-crossing freezes the region (pause now freezes the active world: level or region).

Verified: traversal_check PASS (placement, block, stream, gate, arrival, adoption, abort) · all prior suites PASS · bridge e2e PASS · real boot clean. CI gains traversal_check.

— Lantern

**Stacked on PR #10** (← #9 ← #8); the diff to review is the single traversal commit. The layered architecture's missing stratum lands: World / **Traversal** / Player / Avatar as real scene-tree structure. The traversal region lives in its own slot under the persistent Game root — sibling to the world, owned by neither level. **How a crossing works** 1. **Connections are data**: a level descriptor's `connections` list names the levels reachable on foot (`LevelRegistry.connections`). Sandbox connects to itself so the whole machine is testable end-to-end with the one real level. 2. In GAME mode, Game places a **path asset** (worn strip + gate posts + trigger) at the level's **traversal anchor** — sandbox gains one at its south edge. Walk the path, cross the trigger, the crossing starts. 3. The **region** loads: one corridor scene for every crossing — dense fog (its own Environment), a walled walkway textured with the 5m triplanar grid (the supplied test texture, put to work), monolith silhouettes to judge movement by. The origin world **unloads behind you**; the destination **streams in threaded** while you walk — the walk is the loading screen, and the **exit gate stays dark until the destination is ready**, then lights. 4. Reaching the open gate **adopts** the destination (`LevelLoader.adopt`), frees the region whole, and places you at the new level's traversal anchor. **The rulings, implemented as structure, not checks** - **No saving in the region**: `save_blocked` for the whole crossing — pause Save writes nothing (asserted), Load is gated too. - **Deletion on exit**: the region is freed whole; when the item system lands, anything whose held-by chain roots in the region goes with it. Carried gear roots at the player and survives. (Nothing to drop yet — the law is in the lifetime, ready for items.) - **NPCs** have no way in — there's no NPC system yet, but the region is not part of any level they could path into; the fog-as-refuge property is structural when they arrive. - **Return to Menu mid-crossing aborts** back to the origin level, and **pausing mid-crossing freezes the region** (pause now freezes the *active world*: level or region). **Verified**: traversal_check PASS (placement, block, stream, gate, arrival, adoption, abort) · all prior suites PASS · bridge e2e PASS · real boot clean. CI gains traversal_check. — Lantern
SaveGame gains the working file boundary the design always
planned: save_state/load_state/has_state — a state Dictionary as
human-readable JSON, backup-first (an overwrite copies the old
file to .bak before writing). The #28 full-snapshot skeleton stays
as the documented refinement of what goes inside the dict.

Game wiring:
- New Game writes the run slot (user://saves/run.json)
- Returning to the menu from a game re-dumps, rig still where the
  player stopped — Continue resumes from exactly there
- Continue reads the slot back: level (re)load if it differs, rig
  transform restored wholesale via PlayerRig.place_at_transform
- Continue button lights up whenever a save exists (boot + every
  return to menu refresh it)
- Sandbox never reads or writes the save (throwaway by design)

tests/save_check.gd: unit round-trip + backup contents, boot
no-op Continue, dump/restore pose round-trip through the real
Game flow, sandbox byte-isolation. SAVE CHECK PASS; smoke, arms,
addon suites and the bridge e2e still PASS.
CI: Forgejo Actions test workflow (push + PR, status only)
Some checks failed
tests / test (pull_request) Failing after 14s
1aa6b8be12
Runs everything automatable on the pinned Godot 4.6.3 standard
build, downloaded per run:
- project import
- the headless suites (smoke_menu, arms_check, addon_check,
  save_check; terrain_check joins automatically once it lands —
  branches without it skip it in the open). The suites exit 0
  either way, so the step greps their PASS/FAIL output
- the debug bridge e2e, under Xvfb, which exits non-zero itself

No artifacts are uploaded, nothing is published, no release job —
the workflow only reports test status.

Verified locally: YAML parses; the suite step's script runs all
four suites green and exits 0; a deliberate FAIL probe makes it
exit 1.
Placement negates velocity; strafe on; harness-proof the suites
Some checks failed
tests / test (pull_request) Failing after 19s
fb3998af7d
Zach's bug (found testing main): walk off the sandbox, fall, return
to the menu, New Game — the body's stored fall velocity punched the
player through the floor. Rule (game-planning #28): placement means
at rest, and velocity is never saved. PlayerRig.place_at and
place_at_transform now zero the body's velocity and ground control
via _reset_motion().

Also: MovementDirect's strafe flag was never set (library default
off = stick X discarded = forward/back only). Strafing is the
decided scheme (game-planning #19); strafe = true in the rig scene.

Test fallout, all harness-only (real boots verified clean): in
--script mode a script keeps its first compile, which runs before
autoload identifiers register — the rig's typed children stay Nil
in any suite loading the game scene, and a mid-body script error
once hung save_check by re-running its body on dirty state.
- save_check: completion latch (abort reports FAIL, never loops);
  strafe read from scene state; velocity assertions moved out
- tests/rig_motion_check.gd (new): clean-process unit check that
  loads rig+body scripts after the broken window and exercises the
  real place_at_transform — velocity and ground control zeroed
- CI suite list gains rig_motion_check

Verified: save_check PASS, rig_motion_check PASS, smoke/arms/addon
PASS, bridge e2e PASS.
Pause menu + control settings (hold by_button 2s)
Some checks failed
tests / test (pull_request) Failing after 19s
cfcb78988d
Implements Zach's 2026-10-22 control rulings (game-planning #19):

- HOLD the upper face button (by_button, B/Y) for 2s to pause; the
  press action stays unallocated. Controller menu button and desktop
  Esc open the same pause (they no longer teleport straight to the
  menu place — Return to Menu inside pause does that now).
- Pausing freezes the world (level subtree process mode) and
  anchors the rig in place; the pause panel — a second MenuPanel
  under the persistent Game root — is placed in front of the
  player's view. Buttons: Resume / Options / Save / Load / Return
  to Menu, all functional (no stubs needed).
- Save inside pause writes the run in place; Load restores it
  wholesale; both are GAME-mode only and Save also honours
  Game.save_blocked — the traversal rule that saving is impossible
  there (game-planning #28). The flag is the traversal layer's hook;
  nothing sets it yet.
- Options page = the control settings surface: movement-stick side
  (left/right), turn mode (snap/smooth), snap angle, smooth speed.
  Persisted by GameSettings to user://settings.cfg — their own
  file, separate from the run save. PlayerRig.apply_settings writes
  turn params onto the provider and re-homes the two providers
  between controllers for the stick side (providers re-resolve
  their controller on _enter_tree).
- MenuPanel generalised: its menu control is found by the
  action_selected signal, not by MainMenu type, so main and pause
  panels share the one panel implementation.

Verified: pause_check PASS (pause machine, save gate, settings
round-trip, provider re-homing), save/smoke/arms/addon/rig_motion
PASS, bridge e2e PASS, real boot clean.
CI: install xvfb before the bridge e2e
Some checks failed
tests / test (pull_request) Failing after 17s
ae114a5108
Same fix as on menu-save-v1: the runner image lacks xvfb-run,
which the bridge e2e step needs. Install when missing (sudo only
when not already root).
CI: fetch xvfb from the Debian archive (bullseye is EOL)
Some checks failed
tests / test (pull_request) Failing after 40s
3233031032
Same fix as on menu-save-v1 — archive.debian.org sources for the
xvfb install; see that commit for the run-log evidence.
Full avatar skeleton on the rig + body anchoring
Some checks failed
tests / test (pull_request) Failing after 32s
9ae3df5ade
Zach's rig requirement (game-planning #19): the rig carries the
full avatar-contract skeleton even with no mesh skinned to it,
with mock collisions for IK — one skeleton, arms now, body later.

- src/player/avatar_skeleton.gd: the 51-bone contract built from a
  data table in code (names are Resonite's ExampleAvatarRig
  verbatim, chest -> Chest; the 39 verified arms bones + Hips,
  Spine, Neck, Head + Upper_Leg/Lower_Leg/Foot/Toe per side — the
  arms README's count of 54 is noted in the file; 3 bones are
  unaccounted for and get added when identified, per Zach's
  build-anyways call). Rest pose: standing, facing -Z, +Y along
  each bone. Mock collision volumes (layer 10 Area3D, root-level
  mocks at bone rest positions) for the torso/head/limb bones.
- Rig scene gains an Avatar node carrying it.
- Anchoring: PlayerRig.body_floor_transform() (body ground
  position, yaw only) now roots BOTH the avatar and ArmsView every
  frame — the arms/skeleton live in the body root, not the
  tracking root. This is the fix for Zach's 2026-10-21 headset
  findings (shoulders at a fixed height; arms not following the
  player's physical position): ArmsView's authored 180-degree yaw
  is composed on top, and its solver already converts controller
  world transforms through the skeleton's global transform, so
  the solving itself is untouched.
- tests/skeleton_check.gd (new, clean-process): exact contract
  names/parents/rest positions/sides, mock collisions, anchoring
  math. Added to the CI suite list.

Verified: skeleton_check PASS (34 assertions); smoke/arms/addon/
save/rig_motion/pause PASS; bridge e2e PASS; real boot clean.
CI: dump the e2e game log on failure; add mesa DRI drivers
Some checks failed
tests / test (pull_request) Failing after 39s
9390ef9b27
Same change as on menu-save-v1 (see that commit for the run-log
evidence): print /tmp/da_game_e2e.log when the e2e fails, and
install mesa DRI/GLX with xvfb.
Traversal layer v1: walk the fog between maps
Some checks failed
tests / test (pull_request) Failing after 16s
c40b11f791
The layered architecture's missing stratum (game-planning #8):
World / Traversal / Player / Avatar, each with its own lifetime —
the region is a sibling of the world under the persistent Game
root, loaded only for a crossing, never saved.

- Connections are data: a level descriptor's "connections" list
  names the levels reachable on foot through the fog
  (LevelRegistry.connections). Sandbox connects to itself so the
  machinery is testable end-to-end with one real level.
- Level.traversal_anchor(): the edge point where paths attach and
  arrivals emerge (sandbox gains a TraversalAnchor at its south
  edge). Game places a TraversalPath asset there per connection
  in GAME mode — a worn dirt strip + gate posts + a trigger volume
  at the fog end.
- TraversalRegion (src/traversal/): one corridor scene serves
  every crossing — dense fog (its own Environment), walled walkway
  on Zach's 5m triplanar grid texture, monolith silhouettes, and
  an exit gate that stays dark until the destination has streamed
  in. Walking in starts a threaded load of the destination; the
  walk is the loading screen. Reaching the open gate adopts the
  new level (LevelLoader.adopt) and frees the region whole.
- The rulings, in code: saving is blocked for the whole crossing
  (Game.save_blocked — pause Save writes nothing, Load is gated
  too); the origin world unloads behind the player; the region is
  freed whole on arrival, so anything left inside is deleted with
  it — carried gear survives because it roots at the player.
  Return to Menu mid-crossing aborts back to the origin level.
  Pausing mid-crossing freezes the region (pause now freezes the
  active world: level or region).
- tests/traversal_check.gd: state machine end to end (placement,
  block, stream, arrival, abort). Added to the CI suite list.

Verified: traversal_check PASS; smoke/arms/addon/save/rig_motion/
pause/skeleton PASS; bridge e2e PASS; real boot clean.
CI: install the X11 client libraries Godot's display server needs
Some checks failed
tests / test (pull_request) Failing after 18s
5f69cca483
Run 2990's dumped game log gives the actual root cause of the e2e
startup exit (not GL): 'libXcursor.so.1: cannot open shared object
file' -> the X11 display server fails, the Wayland fallback fails
with it, Godot exits 1 before the bridge ever starts. xvfb does
not pull the X client libraries in. Add libxcursor1 plus the rest
of Godot's usual X11 runtime set (xinerama, xrandr, xi, xkbcommon).
CI: isolated rerun (no code change)
Some checks failed
tests / test (pull_request) Failing after 17s
d85cdb9353
muse-lantern force-pushed traversal-v1 from d85cdb9353
Some checks failed
tests / test (pull_request) Failing after 17s
to 19bd3af5f6
Some checks failed
tests / test (pull_request) Failing after 17s
2026-10-23 18:58:20 +00:00
Compare
docs/input: menu key + Esc pause on non-SteamVR is settled behavior
Some checks failed
tests / test (pull_request) Failing after 18s
c7677ab197
Zach's ruling (2026-10-23): on non-SteamVR setups the menu key
and desktop Esc opening the pause menu is permanent, intended
behavior — the input system will express it as the oculus/openxr
preset binding rather than a direct call. No runtime-gating in
game code: under SteamVR the menu press is reserved and never
reaches the app, so the scoping lives in the bindings.
muse-lantern force-pushed traversal-v1 from c7677ab197
Some checks failed
tests / test (pull_request) Failing after 18s
to 6812d7b587
Some checks failed
tests / test (pull_request) Failing after 24s
2026-10-23 19:25:29 +00:00
Compare
Input: tab-indent the generated JSONs; doc the identity invariant + remappability
Some checks failed
tests / test (pull_request) Failing after 18s
06e6492fc2
Formatting rule (Zach): JSON files we generate use TAB
indentation. Content is unchanged (input_check parses them
semantically).

docs/input-system.md gains two settled points from the ABXY
deep-dive: (1) contract identity is (hand, id) — a shared
positional name never merges physical buttons; A/B/X/Y stay four
distinct inputs because OpenXR state is queried per subaction
path and each controller node reads only its own hand;
(2) bindings are owned by each runtime's setup, 100% where
possible — the presets are suggested defaults, and game code
reads action/contract state only, so user remapping is honored
by construction.
muse-lantern force-pushed traversal-v1 from 06e6492fc2
Some checks failed
tests / test (pull_request) Failing after 18s
to 750c6062f7
Some checks failed
tests / test (pull_request) Failing after 18s
2026-10-23 20:22:32 +00:00
Compare
muse-lantern force-pushed traversal-v1 from 750c6062f7
Some checks failed
tests / test (pull_request) Failing after 18s
to aad321f071
Some checks failed
tests / test (pull_request) Failing after 17s
2026-10-23 20:50:22 +00:00
Compare
muse-lantern force-pushed traversal-v1 from aad321f071
Some checks failed
tests / test (pull_request) Failing after 17s
to daddfcb5c6
Some checks failed
tests / test (pull_request) Failing after 1m0s
2026-10-23 22:36:49 +00:00
Compare
muse-lantern force-pushed traversal-v1 from daddfcb5c6
Some checks failed
tests / test (pull_request) Failing after 1m0s
to 2c3dce6551
Some checks failed
tests / test (pull_request) Failing after 1m1s
2026-10-25 00:45:36 +00:00
Compare
muse-lantern force-pushed traversal-v1 from 2c3dce6551
Some checks failed
tests / test (pull_request) Failing after 1m1s
to 034a9bceb7
Some checks failed
tests / test (pull_request) Failing after 49s
2026-10-25 00:52:49 +00:00
Compare
First-time contributor

@muse-lantern Could you restack this one? It's still stacked on #10, which was closed in favor of #14.

The first two commits on traversal-v1 are the #10 work, with the same SHAs as #10's branch:

  • ec04869 Full avatar skeleton on the rig + body anchoring (the 51-bone skeleton)
  • a583392 PlayerRig: head-anchor the arms view

Merging this PR as-is would put both into main, including the head anchor that #14 has since removed. The one that matters most is a583392. It roots the arms HEAD_DROP (0.3 m) below the camera, but the arms model is rooted at the floor, so the shoulder joints end up about 1.3 m above the eyes and the arms hang from overhead. That is the bug reviewed on #14, and #14 replaces this work: 9aa5965 rebuilds the skeleton with 54 bones, 27fe929 drops the head anchor, and the anchor plan agreed in that review goes further.

Could you rebase onto main and drop those two commits? Rebasing onto main after #14 merges works just as well, as long as the two commits are dropped. The traversal commits (5bb0663, 034a9bc) don't use anything from them. A git merge-tree dry run shows a single conflict either way, the suite list in .forgejo/workflows/tests.yml: keep main's list and add traversal_check. The "Stacked on PR #10" line in the description can go too.

Thanks!

@muse-lantern Could you restack this one? It's still stacked on #10, which was closed in favor of #14. The first two commits on `traversal-v1` are the #10 work, with the same SHAs as #10's branch: - `ec04869` Full avatar skeleton on the rig + body anchoring (the 51-bone skeleton) - `a583392` PlayerRig: head-anchor the arms view Merging this PR as-is would put both into `main`, including the head anchor that #14 has since removed. The one that matters most is `a583392`. It roots the arms `HEAD_DROP` (0.3 m) below the camera, but the arms model is rooted at the floor, so the shoulder joints end up about 1.3 m above the eyes and the arms hang from overhead. That is the bug reviewed on #14, and #14 replaces this work: `9aa5965` rebuilds the skeleton with 54 bones, `27fe929` drops the head anchor, and the anchor plan agreed in that review goes further. Could you rebase onto `main` and drop those two commits? Rebasing onto `main` after #14 merges works just as well, as long as the two commits are dropped. The traversal commits (`5bb0663`, `034a9bc`) don't use anything from them. A `git merge-tree` dry run shows a single conflict either way, the suite list in `.forgejo/workflows/tests.yml`: keep `main`'s list and add `traversal_check`. The "Stacked on PR #10" line in the description can go too. Thanks!
Author
Member

Confirmed, and thanks for catching this before it merged.

I checked the branch: the stack is exactly as you describe — 034a9bc and 5bb0663 on top of a583392 and ec04869. Dropping both #10 commits is right: a583392 is the dangerous one (the head anchor that hangs the arms from overhead, since the model is floor-rooted), and ec04869's 51-bone skeleton is superseded by #14's rebuild anyway. The traversal commits don't touch player_rig.gd or the avatar skeleton, so the only overlap I see is .forgejo/workflows/tests.yml, matching your merge-tree result — keep main's suite list and add traversal_check, and drop the "Stacked on #10" line.

One timing note: #14 is still open, so a rebase onto current main now gives a clean stack but still needs a second pass once #14 lands. I'll take the rebase that way (drop both commits, current main as the base) and re-rebase after #14 merges, rather than waiting on #14 first.

Confirmed, and thanks for catching this before it merged. I checked the branch: the stack is exactly as you describe — `034a9bc` and `5bb0663` on top of `a583392` and `ec04869`. Dropping both #10 commits is right: `a583392` is the dangerous one (the head anchor that hangs the arms from overhead, since the model is floor-rooted), and `ec04869`'s 51-bone skeleton is superseded by #14's rebuild anyway. The traversal commits don't touch `player_rig.gd` or the avatar skeleton, so the only overlap I see is `.forgejo/workflows/tests.yml`, matching your merge-tree result — keep main's suite list and add `traversal_check`, and drop the "Stacked on #10" line. One timing note: #14 is still open, so a rebase onto current main now gives a clean stack but still needs a second pass once #14 lands. I'll take the rebase that way (drop both commits, current main as the base) and re-rebase after #14 merges, rather than waiting on #14 first.
Some checks failed
tests / test (pull_request) Failing after 49s
This pull request has changes conflicting with the target branch.
  • .forgejo/workflows/tests.yml
  • scenes/game.tscn
  • src/player/avatar_skeleton.gd
  • src/player/player_rig.gd
  • tests/skeleton_check.gd
View command line instructions

Manual merge helper

Use this merge commit message when completing the merge manually.

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u traversal-v1:muse-lantern-traversal-v1
git switch muse-lantern-traversal-v1

Merge

Merge the changes and update on Forgejo.

Warning: The "Autodetect manual merge" setting is not enabled for this repository, you will have to mark this pull request as manually merged afterwards.

git switch main
git merge --no-ff muse-lantern-traversal-v1
git switch muse-lantern-traversal-v1
git rebase main
git switch main
git merge --ff-only muse-lantern-traversal-v1
git switch muse-lantern-traversal-v1
git rebase main
git switch main
git merge --no-ff muse-lantern-traversal-v1
git switch main
git merge --squash muse-lantern-traversal-v1
git switch main
git merge --ff-only muse-lantern-traversal-v1
git switch main
git merge muse-lantern-traversal-v1
git push origin main
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
dead-air/engine!11
No description provided.